managing state¶
hold values in fields and let goo rebuild after each handler fires; reach for State<T> when a value changes outside a handler, and a cell when state must survive a parent rebuild
by the end of this guide you will have a small stateful widget: a toggle and a hit counter that track their own values with plain fields, an uptime counter that ticks on its own and needs State<T>, and a cell-based toggle whose state survives no matter how many times the parent rebuilds around it. you will see why plain fields cover most cases, when a value needs State<T> instead, and exactly where a cell picks up where both cannot.
the basic state loop¶
state in goo is a field on your panel class. Build() reads it. mutate the field inside an event handler (OnClick, OnMouseMove, and so on) and goo rebuilds automatically once the handler returns, so you never call Rebuild() yourself for this case. that is the entire loop.
the counter from your first counter is the simplest version: one int _count field, one OnClick that mutates it. 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,
Children = { new Text( _on ? "on" : "off" ) },
},
new Container
{
Padding = 8,
BackgroundColor = Color.Orange,
BorderRadius = 6,
OnClick = _ => _hits++,
Children = { new Text( $"hits: {_hits}" ) },
},
},
};
}
click the toggle - it flips. click the counter - it climbs. both follow the same pattern: mutate the field and let the handler's automatic rebuild pick up the new value on the next Build().
state that changes outside a handler¶
the auto-rebuild above rides along with event dispatch: goo invokes your handler, then requests a rebuild once it returns. a Tick override, a network callback, or a timer does not go through that dispatch, so a plain field mutated there leaves the display stale until something unrelated forces a rebuild.
State<T> closes that gap. create one with Track(initial) and mutate it through .Value; the write marks the panel dirty itself, no dispatch required. writing an equal value is a no-op, so sampling a value every frame does not spam rebuilds when nothing actually changed.
extend the widget with an uptime counter that climbs once a second on its own:
public class StateWidget : GooPanel<Container>
{
readonly State<int> _uptime;
float _elapsed;
bool _on;
int _hits;
public StateWidget() => _uptime = Track( 0 );
protected override bool Tick( float dt )
{
_elapsed += dt;
if ( _elapsed < 1f ) return false;
_elapsed -= 1f;
_uptime.Value++;
return false;
}
protected override Container Build() => new Container
{
Padding = 16,
Gap = 12,
BackgroundColor = Color.White,
BorderRadius = 12,
Children =
{
new Text( $"uptime: {_uptime.Value}s" ),
new Container
{
Width = 120,
Height = 44,
BorderRadius = 8,
AlignItems = Align.Center,
JustifyContent = Justify.Center,
BackgroundColor = _on ? Color.Green : Color.Gray,
OnClick = _ => _on = !_on,
Children = { new Text( _on ? "on" : "off" ) },
},
new Container
{
Padding = 8,
BackgroundColor = Color.Orange,
BorderRadius = 6,
OnClick = _ => _hits++,
Children = { new Text( $"hits: {_hits}" ) },
},
},
};
}
Tick runs every frame regardless of the build gate, which is exactly why a plain field would go stale there: nothing dispatches an event to trigger the auto-rebuild. _uptime.Value++ marks the panel dirty on the one frame it actually changes; every other frame the equal-value check makes the write a no-op. _on and _hits stay plain fields since they only ever change inside a handler.
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 ); },
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. the same auto-rebuild from the basic state loop applies here too: OnClick is a handler, so goo rebuilds once it returns and neither _on nor OnChanged needs a manual Rebuild() call. reach for Cell's own Rebuild() (or Track(), which works on a cell exactly like it does on a GooPanel) only when the cell's state changes outside a handler.
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 ) );
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 = _ => { },
Children = { new Text( "rebuild parent" ) },
} );
return col;
}
}
the "rebuild parent" button's handler does nothing on its own; goo rebuilds after any handler fires, so an empty handler is enough to force the parent through another Build() and prove the cell survives it.
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¶
three patterns, one idea each:
- plain field - the basic state loop. the panel owns the value,
Build()reads it, a handler mutates it and goo rebuilds automatically once the handler returns. this handles the vast majority of stateful panels. State<T>viaTrack()- for a value that changes outside a handler:Tick, a timer, a network callback, a poll. writing.Valuemarks the owner dirty itself; an equal write is a no-op. works the same way on aGooPanelor aCell.- 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.