View Composition
Views compose hierarchically. A parent view creates child views, wires their bindings, and adds them to the presentation layer. The view tree does not need to mirror the model tree - views are structured for presentation needs, not model structure.
Related: Views (Learn) · Model Composition · Bindings in Depth · Presenting Collections · Taming Complex Views
Assumes familiarity with Views and Bindings.
Parent Views Wire Child Bindings
Views compose hierarchically. A parent view creates child views, each with its own bindings, and adds them to the presentation layer.
The top-level application view typically takes the model(s) itself and wires bindings for each child. Child views know nothing about the model tree - they only see their own query and relay bindings:
function GameView(bindings: GameViewBindings): Container {
const { model } = bindings;
return (
<container>
<GridView
rows={() => model.grid.rows}
cols={() => model.grid.cols}
tileKindAt={(r, c) => model.grid.tileAt(r, c)}
/>
<HudView score={() => model.score.score} />
<KeyboardInputView
onDirectionChanged={(dir) => { model.playerInput.direction = dir; }}
/>
</container>
);
}Each child view is independent - it only knows about the bindings it needs. The parent could equally build its container in plain TypeScript and call GridView({ ... }) and the rest, adding each result as a child; see Views.
View Hierarchy
The top-level view is application-specific and takes the model itself. Leaf views are reusable and take query and relay bindings. This matches the access patterns described in Bindings in Depth.
Multiple Views, One Model
Because models are the single source of truth and views only read state through bindings, multiple views can project from the same model data and are guaranteed to be perfectly in sync - without any coordination code between them.
The ticker enforces this: all models update first, then all views refresh. By the time any view reads a binding, every model has finished advancing. No view sees a half-updated world, and no view needs to notify another that something changed.
Example: Same property, different reactions
A game phase property might be read by two views:
- A grid view rebuilds tiles on reset.
- An overlay view shows a game-over message.
Both are wired to the same model property:
// In the top-level view
<GridView phase={() => model.phase} /* ... */ />
<OverlayView phase={() => model.phase} /* ... */ />The same model property drives two independent presentational responses - no event wiring, no shared mutable flag, no risk of one view seeing 'playing' while the other sees 'game-over'.
Adding views without changing existing ones
Adding a new view that reads existing model state requires zero changes to other views. Wire the bindings, and the new view automatically stays in sync. This is a direct consequence of the pull-based architecture: views pull model state each frame rather than subscribing to events from other views.
Dynamic Child Views
When the number of game objects changes at runtime (asteroids split, bullets fire and expire), the parent view needs a child view per object. The simplest way is a <List>, which projects a collection: one item view per slot, built once and reused as the collection grows, shrinks and changes:
<List items={model.asteroids}>
{(asteroid) => (
<AsteroidView x={() => asteroid().x} y={() => asteroid().y} radius={() => asteroid().radius} />
)}
</List>The item view receives asteroid, an accessor for whatever occupies its slot this frame, so its bindings always follow the current item. See Presenting Collections for the shapes of list this covers, and what each costs.
A parent with a plain TypeScript body can call List({ items, children }) just the same, or manage its child views by hand, using change detection to rebuild them only when the count changes:
const watcher = watch({
asteroidCount: () => model.asteroids.length,
});
function refresh(): void {
const w = watcher.poll();
if (w.asteroidCount.changed) {
rebuildAsteroidViews();
}
}For the full change detection pattern, see Change Detection.
Shared Values in Deeply Nested Views
Views deep in the scene hierarchy sometimes need the same values, such as common colours, text styles, or layout sizes. Passing them down as bindings bloats every intermediate view that does not need them itself.
In most games, the simplest solution is to import them from a shared constants module. For example:
import { BUTTON_SIZE, LABEL_STYLE } from './view-constants';This is the simplest fix, and it suits views that belong to one game. The values have one home, so changing a colour there updates every view that uses it. The cost is that an imported value is not in the view's bindings, so neither a caller nor a test can supply a different one. That matters only for values that vary, such as a palette picked at startup, and for views meant for reuse, such as a HUD panel shared by several games.
When a view is reused, keep what it needs in its bindings, so its bindings type lists everything it depends on and each caller supplies its own. Three ways cut the cost of passing values down:
- Hand over a built child. A view that only places a child can take it ready-made, and never see the child's bindings:
<ToolbarView perfmon={<PerfmonView performanceMetrics={metrics} />} />. - Group what travels together. One
themebinding holding a palette, text styles and a formatter costs each level one line rather than several. - Make related views in one function.
createHudViews(palette)returns views that close over the palette, supplied once for the whole group. Their own bindings no longer show it, so keep this for views always used together.
Some UI frameworks also let a view look a value up from whichever ancestor supplies one; React and SolidJS call this context. MVT has no equivalent: a value found that way is a dependency the view's bindings do not show, and a missing one fails only when the view runs.
Model-View Mapping
Views do not need to be 1:1 with models. The view tree is structured around presentation needs, which often differ from the model's domain structure.
When 1:1 is natural
For simple games, each object in the model has a corresponding view - a ball model has a ball view, a paddle model has a paddle view. The view and model trees happen to look similar because the presentation maps directly to the domain.
When 1:1 breaks down
- Multiple views per model. A single game model might be read by a grid view, a HUD view, an overlay view, and a minimap view. There is one model but four views.
- Views with no model counterpart. Decorative elements (background parallax, particle effects, screen transitions) exist purely in the view tree and have no model.
- Models with no view. Some models are internal (e.g. a collision system or an AI planner) and are never rendered directly. Their effects are visible through other models that views do read.
- Different granularity. A model might expose a flat list of game objects, while the view groups them by screen region for rendering efficiency. Or a model might have deeply nested children that a single view reads through bindings without mirroring the nesting.
The key principle: bindings decouple the view tree from the model tree. Because views read state through query bindings rather than navigating model internals directly, the two trees can be shaped independently.
For more on how the model side composes, see Model Composition.