cells¶
composition teaches the load-bearing pattern: one stateful root, stateless presenters below it. that pattern has a documented limit: sub-elements that need UI-local state which exists nowhere in your model, at several levels of a tree whose shape changes at runtime. an expand-collapse node, an in-flight drag buffer, a per-item edit field. hoisting all of that to the root turns Build() into a hand-maintained shadow tree.
a cell is the tool for that case: a self-owning stateful unit nested inside the tree. it holds its own fields across rebuilds, so interaction state lives where it is used instead of on the root.
this article assumes you have read the model, the build method, and composition.
what a cell is¶
a Cell<TRoot> is authored exactly like a GooPanel<TRoot>: a class with state fields, a Build() that returns one root blob, and a Rebuild() you call after mutating state. the difference is where it lives.
GooPanel<TRoot> |
Cell<TRoot> |
|
|---|---|---|
| is an engine component? | yes | no, a plain object |
| driven by | the engine, every frame | the reconciler, expanded in place during the root's diff |
| its panel | engine-provided | none, it expands to its built blob |
| role | the one root per surface | interior unit, nested arbitrarily deep |
the instance persists across the root's rebuilds. each rebuild, the reconciler finds the cell at its slot, pushes fresh props in, and asks it to build. the cell's own fields survive untouched.
authoring a cell¶
sealed class Spinner : Cell<Container>
{
int _tick;
protected override Container Build() => new Container
{
OnClick = _ => { _tick++; Rebuild(); },
Children = { new Text( $"tick {_tick}" ) },
};
}
_tick is UI-local state. no field for it on the root panel, no callback threading. the cell mutates its own field and calls Rebuild().
mounting a cell¶
Cell.Mount<TCell> plants a cell in a parent's child list (or as a subtree root) from inside Build(). you never name the element type it returns.
// no-prop cell, unkeyed (positional identity is fine at a fixed slot):
Cell.Mount<Spinner>()
// keyed cell with a prop refreshed each rebuild:
Cell.Mount<DragSource<Item>>( key: item.Id, configure: d => d.Payload = item )
// initial state via seed, live props via configure:
Cell.Mount<ToggleCell>(
seed: c => c.InitialOn = initialOn,
configure: c => c.OnChanged = onChanged )
seed and configure¶
the two delegates split a cell's inputs by lifetime.
configure runs against the persistent instance before every Build(). it is where the parent pushes fresh props in: payloads, callbacks, anything that should track the parent's current state.
seed runs exactly once, when the instance is first created, before the first configure. it is the structural home for initial values: state the cell owns from then on, which a re-running configure must never reset. without seed, every cell would need a hand-rolled bool _seeded guard in Build(). with it, the initial-vs-live distinction is part of the mount call.
two edge rules:
- if the same field is set in both delegates,
configurewins on first mount, because it runs second. - a seed runs again only when the slot genuinely creates a fresh instance: first mount, or a same-slot change to a different cell type.
keying¶
a cell follows the same keying rules as every blob. an unkeyed cell takes positional identity, which is correct only at a stable index. key any cell that lives in a list that reorders, inserts, or removes, so its state follows its item across the change. do not mix keyed and unkeyed cells in one sibling list.
this matters more for cells than for plain blobs: a mis-keyed container repaints wrong for a frame, but a mis-keyed cell hands one item's accumulated state to a different item.
the rebuild model¶
Rebuild() on a cell marks the owning root dirty, the same flag the root's own Rebuild() sets, so calling it any number of times in one frame still costs one rebuild. the next tick re-runs the whole tree's Build() and re-diffs it. only changed nodes produce ops, so an unchanged cell costs nothing to apply.
teardown¶
when a cell leaves the tree (keyed removal, a shrinking unkeyed list, or a same-slot change to a different cell type), its instance is dropped. a cell holding only managed state needs nothing here, the GC reclaims it.
if a cell owns something that must be released, an event subscription, a native handle, implement IDisposable and the reconciler calls Dispose() on detach. teardown recurses, so a removed cell also disposes any disposable cells nested inside it. a cell that survives a rebuild is never disposed.
sealed class Watcher : Cell<Container>, IDisposable
{
readonly IDisposable _sub = SomeBus.Subscribe();
protected override Container Build() => new Container
{
Children = { new Text( "watching" ) },
};
public void Dispose() => _sub.Dispose();
}
cells in the library¶
goo's drag-and-drop primitives are cells: DragSource<T>, DropZone<T>, and DragLayer<T> each carry in-flight drag state that belongs to no one's model, which is exactly the case cells exist for. drag-and-drop shows them mounted with Cell.Mount, keyed per item, with configure pushing the shared context and payload in each rebuild:
Cell.Mount<DragSource<InvNode>>( key: $"item-{item.Id}", configure: s =>
{
s.Context = _dnd;
s.Payload = item;
} )
reading that article after this one is the best way to see cells working in a real layout.
when to reach for a cell¶
default to the composition shape: state on the root, stateless presenters. reach for a cell when a subtree needs state that
- exists nowhere in your model (purely interactional: hover-driven expansion, drag buffers, draft text), and
- repeats per item or nests at runtime-determined depth, so hoisting it means parallel bookkeeping arrays or a shadow tree on the root.
a fixed settings panel with one toggle does not need a cell. a virtualized tree of collapsible nodes does.
see also¶
- composition - the root-state-plus-presenters pattern cells extend, and its limits
- build method -
Build(),Rebuild(), and the rebuild gate cells share - dynamic children - keyed child lists, the environment most cells are mounted into
- drag-and-drop - the library's own cells in action