composition

this article assumes you have read how state lives on a GooPanel and how Build() returns a tree and helper functions extract reusable blobs. it ties those ideas together into the load-bearing pattern goo was designed around: a single stateful root and a set of stateless presenters.

the core idea

every field that drives the UI lives on the root GooPanel. child components are stateless. a presenter is just a static method, a plain class, or a sealed class that takes data in and returns a blob subtree. it holds no fields of its own that affect layout, no Rebuild() reference, and no reference to the panel. you pass data and handlers down into it. it builds a subtree from what it receives and returns.

this is not a rule you have to follow. it is the shape that works best when the UI grows.

why all state lives on the root

when state is scattered across many objects, two kinds of trouble appear. first: a handler in object A needs to trigger a rebuild that reflects state in object B. you end up threading Rebuild() references everywhere. second: a rebuild in A produces a subtree that references state in B, and if B has changed between builds, the tree is inconsistent.

neither problem appears when state is on one root. a handler mutates a field and goo rebuilds automatically once it returns; Tick() mutates a field and returns true to keep the panel dirty while a value is still moving; a value that changes outside a handler (a timer, a poll, a network callback) goes through a State<T>, created with Track(), which marks the panel dirty the moment .Value is written, with no manual Rebuild() call. whichever path fires, Build() reads every field fresh from one place. there is no threading and no stale reference. the whole tree is a pure function of the panel's fields at build time. see managing state for all three mechanisms in depth.

the presenter shape

a presenter takes a struct of data and returns a blob. the struct name is domain-specific, not a generic "props" convention. here is the pattern as the hotbar demo uses it:

HotbarView holds the selection index and the spring array. it is not itself the root GooPanel: it is one of several views the demo's actual root (ComposableHudUI) composes and ticks by hand, the structural case for a free-floating view object covered in subtrees that own state below.

sealed class HotbarView
{
    int _selected;
    readonly SpringFloat[] _pop = new SpringFloat[N];

    public Container Build()
    {
        var bar = new Container { Key = "hotbar", FlexDirection = FlexDirection.Row, Gap = SlotGap };
        for ( int i = 0; i < N; i++ )
            bar.Children.Add( Slot( i ) );
        return bar;
    }

    Container Slot( int i )
    {
        bool  selected = i == _selected;
        float pop      = (selected ? SelectedScale : 1f) + _pop[i].Current * PopScale;

        var slot = new Container
        {
            Key             = $"slot-{i}",
            Position        = PositionMode.Relative,
            Width           = SlotSize,
            Height          = SlotSize,
            BackgroundColor = selected ? SelectedBg : SlotBg,
            BorderRadius    = 6f,
            JustifyContent  = Justify.Center,
            AlignItems      = Align.Center,
            Transform       = Goo.PanelTransform.Scale( pop ),
        };

        slot.Children.Add( new Goo.SvgPanel
        {
            Key    = "icon",
            Path   = Items[i].Icon,
            Color  = IconTint,
            Width  = IconSize,
            Height = IconSize,
        } );

        slot.Children.Add( new Container
        {
            Key       = "badge",
            Position  = PositionMode.Absolute,
            Top       = SlotSize - 18f,
            Left      = SlotSize - 26f,
            FontColor = BadgeColor,
            FontSize  = 13f,
            Children  = { new Text( $"x{Items[i].Count}" ) { Key = "n" } },
        } );

        return slot;
    }
}

Slot takes an index, reads from the view's own fields, and returns a subtree. it is not static here because it reads instance fields, but it takes no callbacks and holds no state of its own. the point is the same: data flows in, blob flows out. the badge container is a second child appended after the icon, positioned absolute against the slot (hence Position = PositionMode.Relative on the slot itself): the stack count for that item, bottom right.

the demo this comes from is Code/Demos/ComposableHud/HotbarView.cs. the snippet above trims the class to Build() and Slot(); the full file also has Tick(), Select(), and Reset(), which drive the selection index and the spring array by hand. it also drops the source's defensive PointerEvents = PointerEvents.None on the slot: goo auto-gates pointer events (a blob with no handlers resolves to None on its own), so the declaration is redundant. HotbarView predates State<T>: it is a plain class, not a GooPanel or a Cell, so Track() is not available to it, and it hand-rolls a _dirty flag with Invalidate()/NeedsRebuild() instead. on a GooPanel or a Cell, prefer State<T> (see managing state) for a value like this that changes outside a handler: it marks the owner dirty on write and needs none of that bookkeeping.

the chip presenter: a fully static example

EntryChip in the keystroke visualizer is the cleanest example of the fully static form. it takes a key, a label, timing floats, and an animation config struct, and returns a Container. there is no instance state at all:

public static class EntryChip
{
    public static Container Build( int key, string label, float age, float idleAge, ChipAnim a )
    {
        var p = a.Phase.Project( age, idleAge );

        float yOff    = (a.SlideFromBelow ? 1f : -1f) * a.ChipHeight * a.SlideRiseFraction * (1f - p.Slide);
        float opacity = p.Opacity * a.BaseOpacity;

        return new Container
        {
            Key             = key.ToString(),
            Height          = a.ChipHeight,
            PaddingLeft     = 14,
            PaddingRight    = 14,
            PaddingTop      = 6,
            PaddingBottom   = 6,
            BackgroundColor = Ink2,
            BorderRadius    = 6f,
            Opacity         = opacity,
            Transform       = Goo.PanelTransform.Translate( 0, yOff ),
            JustifyContent  = Justify.Center,
            AlignItems      = Align.Center,
            Children        = { new Text( label ) { FontSize = a.FontSize, FontColor = FgPrimary } },
        };
    }
}

ChipAnim is a readonly record struct. it bundles the animation config the caller passes down. the presenter does not know how the caller stores those values or when they change. it only computes a blob from what it receives.

the calling side constructs the config once, then calls EntryChip.Build for each entry in the queue. r comes from Layout.ResolveAnchor( Anchor ), resolved earlier in Build():

var r = Layout.ResolveAnchor( Anchor );
// ...
var anim = new ChipAnim( new AgePhase( SlideInDuration, HoldTime, FadeOutDuration ),
                          ChipHeight, SlideRiseFraction, FontSize, ChipOpacity, r.StacksUp );

column.Children.AddRange( _queue, ( i, e ) =>
    EntryChip.Build( i, e.Label, _now - e.SpawnTime, _now - e.LastInputTime, anim ) );

this is from Code/Demos/KeystrokeVisualizerUI.cs.

the config struct

ChipAnim is a readonly record struct. that is the idiom for presenter inputs that carry several related values. put display-driving data in it. put handlers in it too, if the presenter fires events back up:

public readonly record struct ChipAnim(
    AgePhase Phase,
    float    ChipHeight,
    float    SlideRiseFraction,
    float    FontSize,
    float    BaseOpacity,
    bool     SlideFromBelow );

the name is domain-specific, not generic. name it after what it describes. ChipAnim describes the animation config for a chip. SquadRow would describe the data for one squad-bar row. pick a name that makes the call site read like prose.

passing handlers down

a presenter that fires user actions passes the callback in its config struct. the root constructs the lambda that closes over the panel's Rebuild():

public readonly record struct RowData( string Name, int Hp, int MaxHp, Action OnKick );

static Container Row( RowData d ) => new Container
{
    FlexDirection = FlexDirection.Row,
    AlignItems    = Align.Center,
    Gap           = 8f,
    Children =
    {
        new Text( d.Name ),
        new Text( $"{d.Hp}/{d.MaxHp}" ),
        new Container
        {
            Padding    = 8,
            OnClick    = _ => d.OnKick(),
            Children   = { new Text( "kick" ) },
        },
    },
};

the lambda that wires OnKick to a field mutation plus Rebuild() lives on the root panel, not inside the presenter. the presenter never calls Rebuild() directly. note the struct is passed by value: an in parameter would not compile here, because the OnClick lambda captures d.

when to extract a presenter vs inline

a flat inline helper is fine for one or two call sites. extract a presenter (its own static class or sealed class) when:

the chip and slot examples both cleared the third bar. inline helpers are the right call for one-off, low-complexity subtrees:

Container Button( string label, Color tint, Action onClick ) => new Container
{
    Key                  = label,
    Padding              = Space3,
    BackgroundColor      = BgCard,
    HoverBackgroundColor = BgCardHi,
    BorderRadius         = Radius1,
    OnClick              = _ => onClick(),
    Children             = { new Text( label ) { FontSize = FontBodySm, FontColor = tint } },
};

here Button is a private instance method on the panel, not a static class, because the subtree is small enough that it does not need the separation. both forms are legitimate. the distinction is scale, not ceremony.

subtrees that own state

a presenter that needs animation state or per-frame input has two options, and only one of them is a goo shape.

the wrong instinct is to grow the presenter into a free-floating "view object": a plain class with fields, a Tick(), and a Build(), that the root holds an instance of and drives by hand. it works, but it is a fourth thing to learn that the framework does not own. the reconciler never sees it, so it does not key, diff, or tear down for you, and the root ends up holding instances and forwarding ticks.

that instinct is not the same gap State<T> closes. State<T>, created with Track() on the root or on a cell, is for a single value that changes outside a handler: mutate .Value and the owner is marked dirty automatically, no hand-rolled _dirty flag needed. reach for the free-floating view-object shape only when a plain class genuinely needs to own and tick several such sub-elements itself, the way ComposableHudUI composes HotbarView and its siblings above: that is a structural composition of multiple views under one root, not a single value State<T> would cover.

the right tool below the root is a cell: a self-owning stateful unit the reconciler does own. you mount it in a function, so the call site stays a plain Foo(...), and the cell keeps its own fields across rebuilds. that is the one stateful path goo gives you below the root. for per-frame animation that the cell cannot drive (a cell has no Tick), keep the springs on the root and pass the sampled values down as presenter inputs, exactly like ChipAnim above.

what this handles well

the composability pattern ran seven HUD elements through this shape. zero changes to the reconciler or fiber were needed. each element was a stateless presenter fed from the root, composed with no edits to the originals.

the pattern works because HUD-class UI is a display projection of game state. animation springs flatten to arrays on the root. nested stateful sub-elements flatten to parallel arrays with per-index indexing. there is no tree-shaped state: it is all flat.

the pattern reaches a limit when sub-elements need UI-local state that does not exist anywhere in the model, at several levels of a tree whose shape changes at runtime. expand-collapse nodes, in-flight drag buffers, per-item focus rings in a deep nested inventory: those cases can require hoisting so much state to the root that Build() becomes a hand-maintained shadow tree. that limit is real but separate from the composition pattern this article teaches. when you hit it, a cell is the tool: a self-owning stateful unit, mounted in a function and nested inside the tree.

recap

see also