Composition Patterns
How to factor reactive UI into reusable functions that compose cleanly with the signal reactivity model. This is the answer to the question that comes up the moment you try to split a view into reusable pieces: how does the helper know what state to read?
The answer is uniform: a reusable view function takes a signal handle for the slice it
renders. It never takes a (s) => … callback, and it never reads the whole component
state. Reactivity flows through signals, and the runtime gates each binding by exactly the
paths its signal reads.
TL;DR — pick the pattern by shape
| Helper shape | Pattern | Composition surface |
|---|---|---|
| Renders a slice of state | 1 — sliced signal | Helper takes Signal<Slice>; caller passes state.at('slice') |
| Renders a list of rows | 2 — each over a sliced list |
Helper takes Signal<Row[]>; per-row item signal feeds the cell bindings |
| Renders a single derived value | 3 — derived signal | Helper takes Signal<T>; caller passes state.map(fn) or a .at() slice |
| Layout chrome (header, sidebar, dialog frame) | 4 — child slots | Helper takes children: ChildNode[]; caller fills slots with its own bindings |
| Library component with its own state machine | 5 — connect() + delegation |
Component exports init/update/connect; parent owns the slice, routes messages |
Pattern 1 — sliced signal (primary)
When: a reusable view function renders a sub-tree of state.
Composition: the helper takes a Signal<Slice>. The caller slices at the call site
with .at('field'). The helper reads via the signal's own .at/.map — no (s) => …
callbacks cross the boundary, and the helper's type is decoupled from the parent's full
state shape.
import { div, text, span } from '@llui/dom'
import type { Signal, Send, Renderable } from '@llui/dom'
type UserSlice = { name: string; email: string; active: boolean }
// The helper only knows about its slice — not the host state type.
// `Renderable` is `readonly Mountable[]` — what every view helper returns.
function userCard(user: Signal<UserSlice>, send: Send<Msg>): Renderable {
return [
div({ class: user.at('active').map((a) => (a ? 'card active' : 'card')) }, [
span([text(user.at('name'))]),
span([text(user.at('email'))]),
]),
]
}
// CALLER — slice the parent state to the shape the helper wants:
view: ({ state, send }) => [userCard(state.at('currentUser'), send)]
What you get:
- The helper's type signature is tight (
Signal<UserSlice>), decoupled from the host. - Each binding inside reads a precise path (
currentUser.active,currentUser.name, …), so the runtime gates it on exactly those paths. - Adding the helper to a new host is just passing the right slice.
Pattern 2 — each over a sliced list
When: a generic helper renders a list of rows whose per-row fields change in place.
Composition: the helper takes a Signal<Row[]>. each gives the row render a per-row
item: Signal<Row> (and an index: Signal<number>). Cell bindings read item.at('field')
so they update surgically when that row's data changes.
import { each, tr, td, text, show, span } from '@llui/dom'
import type { Signal, Renderable } from '@llui/dom'
interface Row {
id: string
title: string
banned: boolean
}
function table(rows: Signal<Row[]>): Renderable {
return [
each(rows, {
key: (r) => r.id, // ← plain id; do NOT include mutable fields
render: (item) => [
tr({}, [
// Reactive cell — re-reads when this row's `title` changes:
td([text(item.at('title'))]),
td([show(item.at('banned'), () => [span({ class: 'badge' }, [text('banned')])])]),
]),
],
}),
]
}
Key points:
item.at('title')is a reactive per-row slot. The runtime mutates kept rows in place rather than recreating them.keyis(r) => r.idonly. Including mutable fields (`${r.id}:${r.editedAt}`) forces a remove+insert of the whole row on every change — focus, scroll position, and transitions all reset.- Read the row id in handlers with
.peek():onClick: () => send({ type: 'select', id: item.at('id').peek() }).
If a cell needs to combine the row signal with a parent signal (e.g. "is this the active
row?"), use derived:
import { derived } from '@llui/dom'
render: (item) => [
tr({ class: derived([item, activeId], (r, active) => (active === r.id ? 'active' : '')) }, [
/* … */
]),
]
Pattern 3 — derived signal (single reactive value)
When: a generic helper renders one reactive value (button label, status badge, error text). No iteration.
Composition: the helper takes a Signal<T> and plugs it directly into a primitive.
The caller does the derivation at the call site with .map or a .at() slice.
import { span, text } from '@llui/dom'
import type { Signal, Mountable } from '@llui/dom'
// Helper takes the already-derived signal — no callback, no host state type.
// A single element helper returns a `Mountable` (materialized when placed).
function statusBadge(className: Signal<string>): Mountable {
return span({ class: className })
}
// CALLER derives against literal state reads:
statusBadge(
state
.at('session')
.at('active')
.map((a) => (a ? 'active' : 'inactive')),
)
The caller's .map reads session.active literally, so the binding's mask is precise. If
the value depends on multiple reads, combine them with derived([sigA, sigB], fn) at the
call site.
Pattern 4 — child slots (layout chrome)
When: a generic helper provides outer-layout structure (header, sidebar, dialog frame, panel) with content rendered by the page.
Composition: the helper takes ChildNode[] slot(s) (ChildNode = Mountable | string | number). The caller fills them with whatever bindings the page needs, tied to its own state.
import { header, nav } from '@llui/dom'
import type { ChildNode, Mountable } from '@llui/dom'
function headerView(opts: { navItems: readonly ChildNode[]; userBadge: ChildNode }): Mountable {
return header({}, [nav({}, opts.navItems), opts.userBadge])
}
// CALLER fills slots with bindings tied to its concrete state shape:
headerView({
navItems: [
a(
{
href: '/dashboard',
class: state.at('route').map((r) => (r === '/dashboard' ? 'active' : '')),
},
[text('Dashboard')],
),
],
userBadge: span({ class: state.at('user').map((u) => (u ? 'auth' : 'anon')) }, [
text(state.at('user').map((u) => u?.name ?? 'Sign in')),
]),
})
The header is no longer a state-generic component — it's a chrome layout that accepts content. Each page's call site fills the slots with bindings for its own state shape.
If the chrome itself has local UI state (isOpen, expanded), model it as a slice the
host owns and pass the sliced signal in (Pattern 1), or — for genuine isolation — use a
full subApp() boundary.
Structural primitives are lazy descriptions — capture and reuse freely.
each/show/branch/unsafeHtml/lazy/virtualEach/foreign/portalreturn aMountable: a recipe that builds its live nodes (and registers its reactive bindings) at the point it is placed, always under the build then in scope. So aMountablestored in a variable and dropped into a slot inside ashow/brancharm rebuilds fresh on every remount — no drained nodes, no disposed-scope reuse. Placing the sameMountablein two slots yields two independent live instances. This just works:// built once, captured, reused across every hide/show — renders correctly each time: const slot = [each(rows, { key, render })] show(open, () => [div({ class: 'contents' }, slot)])
Pattern 5 — connect() + delegated update (library components)
When: embedding a reusable component (dialog, combobox, date-picker) that ships its own
State, Msg, and update.
Composition: this is the convention used across @llui/components. The component
exports pure init / update functions plus connect(state: Signal<Slice>, send, opts?)
which returns reactive props to spread onto elements. The parent owns the slice in its
state, delegates to the component's update, and routes the component's messages through
its own Msg union.
import * as toggle from '@llui/components/toggle'
import { button, text } from '@llui/dom'
type State = { bold: toggle.ToggleState; /* … */ }
type Msg = { type: 'bold'; msg: toggle.ToggleMsg } | /* … */
// Parent update delegates to the component's pure update:
update: (state, msg) => {
switch (msg.type) {
case 'bold':
return [{ ...state, bold: toggle.update(state.bold, msg.msg)[0] }, []]
// …
}
}
// View — connect() returns spreadable, signal-based props:
view: ({ state, send }) => {
const parts = toggle.connect(state.at('bold'), (m) => send({ type: 'bold', msg: m }))
return [button({ ...parts.root, class: 'btn' }, [text('Bold')])]
}
The parent stays type-safe: each component gets a branded message variant
({ type: 'bold'; msg: toggle.ToggleMsg }) so the parent's Msg union is exhaustive and
routing is explicit. A reviewer sees every state transition in one flat switch; an LLM
generates it mechanically from the types.
Components that render an overlay (dialog, popover, tooltip) also export an overlay()
view helper that builds the portal tree and wires accessibility utilities — see the
Composition recipe in the cookbook.
What to avoid
Passing a (s) => T callback across a helper boundary. The signal runtime has no
notion of an accessor callback — reactivity flows through signals. A helper that wants a
reactive value takes a Signal<T>; the caller derives it at the call site.
Reading the whole state signal in a helper. Pass a sliced signal
(state.at('slice')), not the root state. A helper that maps over the entire state
object depends on every field and re-runs on every change.
.peek() in a slot. text(signal.peek()) reads once at build time and never updates.
.peek() belongs in event handlers, effects, and onMount — never as a slot value.
Operating on a signal as if it were a value. signal + 1, `${signal}`,
signal ? a : b operate on the handle, not its contents. Derive: signal.map((n) => n + 1).
Side effects or DOM construction inside a .map body. A derive body must be pure over
plain values — no send/fetch/timers, no .at/.map/.peek on a signal, no element or
text helpers. Use a structural primitive (show/branch/each) to build conditional DOM.
Returning a fresh object/array from .map/derived every call. The reconciler decides
whether to commit a binding by reference equality (Object.is) against the value it last
produced. A derive that allocates a new value each run — state.map((s) => ({ ...s.user })),
state.map((s) => s.items.filter(...)), state.map((s) => [...s.rows]) — is never equal to
its previous output, so it re-commits on every state change even when nothing it reads
changed. This is silent (correct, just wasteful). Prefer narrowing with .at() so the binding
depends only on what it uses, keep derives returning primitives or stable references, and let
each (keyed by id) own list identity rather than mapping to a fresh array in a slot.
When to reach for subApp()
For genuine isolation — embedding an independent app whose lifetime is distinct from the
host's, a library bundle shipping its own complete TEA loop, or an independent effect
lifecycle — use a full subApp() boundary (imported from @llui/dom/escape-hatch; its
own scope tree and update loop; lazy() loads one asynchronously over the same
machinery). Splice its returned nodes with a spread: ...subApp({ name, init, update, view }).
Use it sparingly. A sub-app boundary is a region the unified reactivity model can't see across. The chunked-mask reactivity scales precisely with the number of paths read, not with state depth, so a large flat state shared through sliced signals is fine — reach for view functions first.