managing state¶
hold values in fields, rebuild on change, and keep cell-local state alive across parent rebuilds
by the end of this guide you will have a small stateful widget: a toggle and a hit counter that track their own values, and a cell-based toggle whose state survives no matter how many times the parent rebuilds around it. you will see why fields plus Rebuild() cover most cases and exactly where a cell picks up where a field cannot.
the basic state loop¶
state in goo is a field on your panel class. Build() reads it. when the field changes, you call Rebuild() to schedule a fresh build. that is the entire loop.
the counter from your first counter is the simplest version: one int _count, one OnClick that mutates it, one Rebuild(). here we add a boolean flag next to it to show two fields side by side.
public class StateWidget : GooPanel<Container>
{
bool _on;
int _hits;
protected override Container Build() => new Container
{
Padding = 16,
Gap = 12,
BackgroundColor = Color.White,
BorderRadius = 12,
Children =
{
new Container
{
Width = 120,
Height = 44,
BorderRadius = 8,
AlignItems = Align.Center,
JustifyContent = Justify.Center,
BackgroundColor = _on ? Color.Green : Color.Gray,
OnClick = _ => { _on = !_on; Rebuild(); },
Children = { new Text( _on ? "on" : "off" ) },
},
new Container
{
Padding = 8,
BackgroundColor = Color.Orange,
BorderRadius = 6,
OnClick = _ => { _hits++; Rebuild(); },
Children = { new Text( $"hits: {_hits}" ) },
},
},
};
}
click the toggle - it flips. click the counter - it climbs. both follow the same pattern: mutate the field, call Rebuild(), let Build() re-read it. the diff applies only changed nodes; see build method for how that works.
the limit of fields¶
fields are attached to the panel instance. every piece of state lives in the same place, and every rebuild re-reads all of it together. that is fine for a widget with a fixed structure.
the problem appears when you want a stateful element that is nested inside a larger panel's tree - an expand/collapse node, a per-item toggle in a list. hoisting each element's state up to the root panel means parallel bookkeeping: arrays indexed by item, maps keyed by id. the root accumulates state that belongs to its children.
a cell solves this by giving a nested element its own class with its own fields, exactly like GooPanel<T> does, but planted inside another panel's child list.
introducing a cell¶
a Cell<TRoot> is authored just like a GooPanel<TRoot>: fields, Build(), Rebuild(). the difference is that it is a plain object, not an engine component. the reconciler holds its instance across the parent's rebuilds, so its fields survive untouched when the parent rebuilds around it.
sealed class ToggleCell : Cell<Container>
{
public bool InitialOn { set => _on = value; }
public Action<bool>? OnChanged;
bool _on;
protected override Container Build() => new Container
{
Width = 120,
Height = 44,
BorderRadius = 8,
AlignItems = Align.Center,
JustifyContent = Justify.Center,
BackgroundColor = _on ? Color.Green : Color.Gray,
OnClick = _ => { _on = !_on; OnChanged?.Invoke( _on ); Rebuild(); },
Children = { new Text( _on ? "on" : "off" ) },
};
}
_on is local to the cell. the parent never touches it. OnChanged lets the cell report back when the user clicks, so the parent can react without owning the state.
mounting the cell¶
Cell.Mount<T> plants the cell in the parent's child list from inside Build(). you pass a key so the reconciler can find the same instance across rebuilds, a seed delegate that runs once on first creation to set initial values, and a configure delegate that runs before every build to push fresh props in.
public class StateWidget : GooPanel<Container>
{
bool _lastToggle;
int _parentPasses;
protected override Container Build()
{
_parentPasses++;
var col = new Container
{
Padding = 16,
Gap = 12,
BackgroundColor = Color.White,
BorderRadius = 12,
};
col.Children.Add( Cell.Mount<ToggleCell>(
key: "toggle",
seed: c => c.InitialOn = false,
configure: c => c.OnChanged = on => { _lastToggle = on; Rebuild(); } ) );
col.Children.Add( new Container
{
Key = "echo",
Children = { new Text( $"last toggle: {_lastToggle} parent builds: {_parentPasses}" ) },
} );
col.Children.Add( new Container
{
Key = "rebuild-btn",
Padding = 8,
BackgroundColor = Color.Orange,
BorderRadius = 6,
OnClick = _ => Rebuild(),
Children = { new Text( "rebuild parent" ) },
} );
return col;
}
}
press play and try it:
- click "rebuild parent" several times -
parent buildsclimbs, the toggle stays wherever you left it. - click the toggle - it flips and
last toggleupdates. - click "rebuild parent" again - the toggle is still in the same state.
the cell's _on field is not affected by the parent rebuilding. the reconciler finds the same instance at the "toggle" key each time and only re-runs configure and Build() on it.
seed vs configure¶
seed and configure split the cell's inputs by lifetime.
seed runs once, when the instance is first created. it is the right place for initial values - state the cell owns from that point on. it does not run on subsequent parent rebuilds, so setting InitialOn in seed means "start here, then the cell is in charge."
configure runs before every build, including the first. it is the right place for callbacks and live props - anything that should track the parent's current state. OnChanged goes here because the parent may capture new closures on each rebuild.
if you move InitialOn to configure, the toggle resets to its initial position every time the parent rebuilds. that is the behavioral difference the split exists to prevent.
what just happened¶
two patterns, one idea each:
- field plus
Rebuild()- the basic state loop. the panel owns the value,Build()reads it, a handler mutates it and callsRebuild(). this handles the vast majority of stateful panels. - cell - a self-contained stateful unit nested inside the tree. its fields are invisible to the parent. the reconciler keeps its instance alive across parent rebuilds, so state that belongs to a sub-element lives there instead of being hoisted.
seedsets initial values once;configurepushes fresh props in on every rebuild.
see also¶
- cells - full cell authoring surface: keying, teardown with
IDisposable, and when to reach for a cell vs. the composition pattern. - build method -
Rebuild()mechanics, the structural diff, and how keys drive identity. - your first counter - the field-plus-
Rebuild()pattern in its simplest form. - composition - the root-state-plus-presenters pattern that cells extend, and its limits.