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 ReadSignal<Row[]>; per-row item signal feeds the cell bindings |
| Renders a single derived value | 3 — derived signal | Helper takes ReadSignal<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 |
| Widget whose state nobody else reads | island() (T2) |
Own update loop + mask scope; props in via props/onProps, messages out via onHandle |
Which signal type does a helper take?
There are two kinds of signal, and one type that covers both:
| Type | What it is | .at()? |
|---|---|---|
Signal<T> |
a PATH signal — the view's state, anything from .at(), an each row's item |
yes |
MappedSignal<T> |
the result of .map() / derived() — it has no state path |
no |
ReadSignal<T> |
either of the above: map + peek, no .at() |
no |
Type a helper's parameter ReadSignal<T> unless the helper calls .at() on it, and
Signal<T> when it does. A ReadSignal parameter accepts state.at('x') AND
state.map(fn); a Signal parameter rejects a mapped signal at compile time (a
MappedSignal is not a Signal), which is what you want — .at() on it would throw.
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. It isSignal, notReadSignal, because the helper slices with.at()— so a caller handing itstate.map(pickUser)gets a compile error instead of a mount-time throw. - 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 ReadSignal<Row[]> — each only reads its items, so
the caller may pass a slice (state.at('rows')) or a filtered view
(state.at('rows').map((rs) => rs.filter(visible))). each gives the row render a
per-row item: Signal<Row> (and an index: Signal<number>) — row handles are always
PATH signals, whatever the items signal was. 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 { ReadSignal, Renderable } from '@llui/dom'
interface Row {
id: string
title: string
banned: boolean
}
function table(rows: ReadSignal<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 ReadSignal<T> and plugs it directly into a
primitive. The caller does the derivation at the call site with .map or a .at() slice —
ReadSignal accepts both, where Signal<T> would reject the .map result.
import { span, text } from '@llui/dom'
import type { ReadSignal, 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: ReadSignal<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), decide by who else needs to
read it: if a sibling, the URL, or an undo stack does, model it as a slice the host owns and
pass the sliced signal in (Pattern 1); if nobody does, mount it as an island() (T2 below).
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: ReadSignal<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, mapSend, 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 toggleState = state.at('bold')
const toggleSend = mapSend<Msg, toggle.ToggleMsg>(send, (msg) => ({
type: 'bold',
msg,
}))
const parts = toggle.connect(toggleState, toggleSend)
return [button({ ...parts.root, class: 'btn' }, [text('Bold')])]
}
mapSend<Outer, Inner>(send, wrap) adapts the parent's Send<Outer> into the
Send<Inner> expected by the child. The parent stays type-safe: each component gets a
tagged message variant ({ type: 'bold'; msg: toggle.ToggleMsg }) so the parent's Msg
union is exhaustive and routing is explicit. Even when two child message unions use the
same discriminants (for example, both contain { type: 'reset' }), their distinct parent
variants route them without a cast or ambiguity. 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 ReadSignal<T>; the caller derives it at the call site.
Typing a read-only helper parameter Signal<T>. Signal is the PATH signal — it
promises .at(). A helper that only reads (map, peek, or passing the signal into a
slot) must take ReadSignal<T>, or every caller that derives (state.map(fn),
derived(…)) gets Argument of type 'MappedSignal<…>' is not assignable to parameter of type 'Signal<…>'. The fix is the parameter type, never a cast.
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.
Where a widget's state lives — the three tiers
Every widget's state sits on one of three rungs, and picking the wrong one is the most
common structural mistake in an LLui app. The cliff to avoid is landing T1 and T2 widgets
on T3, which is how a page ends up with thirteen state slices for thirteen copy buttons —
and then with an imperative textContent workaround written behind the reconciler's back.
| Tier | Example | Mechanism |
|---|---|---|
| T1 static — no state after build | meter, sparkline, chip, badge | connect(constant(v), noSend, opts) |
| T2 local — private and transient | copy button, disclosure, tooltip | island({ def }) |
| T3 hoisted — app-level: URL, undo, persistence, siblings | dialog, tabs, form, router-driven | connect(state.at('x'), send) |
Move UP a rung the moment the state stops being private. If it has to survive a route
change, appear in a URL, be undoable, be persisted, or be read by a sibling, it belongs in
the host's State (T3). An island's state is unreachable from the host except through
onHandle, which is a feature until it isn't.
T2 — island()
island({ def }) (from @llui/dom) mounts a component instance with its own update loop,
mask scope and DOM region. It is not registered as a child scope, so the host's
reconciler never walks it — host state changes don't invalidate it and vice versa. It is
disposed with the host, and lazy() loads one asynchronously over the same machinery.
island({ def: CopyButton })
Props go in declaratively, and each change becomes a message rather than a poke at the island's state — so the island's reducer stays the single writer and devtools shows where the value came from:
island({
def: Clipboard,
props: state.at('token'),
onProps: (value) => ({ type: 'setValue', value }),
})
props is a real binding in the host's scope, so it is mask-gated like any other reactive
read: a host update that doesn't touch token sends nothing. Messages come out through
onHandle's handle. reason is optional — a note for the reader, never consulted at
runtime.
Contexts still reach an isolated instance
provide() flows into an island and into a lazy()-loaded component, even though neither
is a child scope. Both snapshot the placing build's context map at placement and hand it
to the instance, so useContext inside the loaded component reads what its ancestors
provided — @llui/components' LocaleContext, an SSR HEAD_SINK, your own contexts alike.
provide(LocaleContext, it, () => [
island({ def: CopyButton }), // reads `it`
lazy({ loader: () => import('./Stats'), fallback: () => [text('…')] }), // reads `it` too
])
Both also take an optional contexts map of extra values, merged over the inherited
ones — a key you name wins, every other ancestor-provided value still arrives.
Placement is the only correct moment to snapshot, and it is why this needed fixing twice
(#231 for island,
#243 for lazy): provide is
immutable-by-swap, so it restores the parent map as soon as its synchronous render()
returns — long before a lazy loader settles. Reading the map at mount time finds no
ancestor provider at all, and the failure is silent: a component that renders fine in
default English, and head entries that vanish from an SSR collector instead of erroring.
An island is not a valid each row root
Wrap it in an element — li([island({ def })]) — the same rule show/branch/each
already carry. Both the server and the client reject it:
render: () => [island({ def: Leaf })] // ✗ island as the row's only node
render: () => [div({}, []), island({ def: Leaf })] // ✗ still wrong — see below
render: () => [li([island({ def: Leaf })])] // ✓ the row has a stable node to key
An island returns an anchor comment and mounts its real body as the anchor's
siblings, so the body is not part of the node list each moves and removes. The rule is
therefore not about being bare: the body sits outside the row however many siblings the
anchor has.
On the server the island's body is a multi-node fragment, which each's stable-row-root
guard has always caught by nodeType. On the client the anchor is a perfectly stable
node, so that guard could not see it — the row rendered, and then corrupted on the first
reorder, the anchors migrating while the mounted bodies stayed put. A suite that never
reorders stayed green on the client while the same source 500'd on the server;
#239 closed that by marking the anchor,
so the two sides now fail identically and immediately, with a diagnostic that names the
anchor mechanism rather than a conditional you did not write. lazy() carries the same
constraint for the same reason.
show/branch arms are unaffected in both directions; only each rows. So is
virtualEach, whose rows each live in their own wrapper element that travels with the row.
On the server
Islands render on the server too — a build plus one mount against the seed state — so they are not a post-hydration pop-in and are not blank without JS.
One limit, and it has a workaround: the server body reflects init()/initialState, not
the first props value. A props-driven island therefore paints its default in the server
HTML, and hydration replaces it with the prop value. A prop is a binding in the host's
scope and is only resolvable once that scope reconciles, which is after the island's body
has already been built — and inside an each row the row ctx it would need does not exist
yet, so resolving it early would be wrong exactly where lists are.
So for a value the server already knows — a locale, a route param, a token — prefer
initialState, which the server does bake into the HTML, over props for the first
paint. Use props for what changes afterwards; the two compose (initialState seeds,
onProps keeps it current).
What an island costs — measured, not feared
At N=500 leaves in jsdom (absolute numbers inflated by the environment; the ratio is the signal):
| mount | 50 host updates | |
|---|---|---|
| inline widget | 22.0 ms | 2.19 ms |
| island | 53.3 ms | 1.14 ms |
Islands cost ~2.4x at mount and are ~2x cheaper on update, because an island is not a child scope so the host reconciler never walks it. That is mount cost traded for update isolation: a good trade for many leaves under a host whose state churns, the wrong one for a handful of leaves under a host that never updates. It is not a reason to avoid the tier — it is the number to decide with.
The other cost is not measured in milliseconds: an island is a region the unified
reactivity model cannot see across. Its state does not appear in the host's state snapshot,
so devtools time-travel, @llui/test replay and the agent protocol see it as a separate
component. That is the right shape for state nobody else owns, and the wrong shape for
anything on the T3 list above.
The deprecated subApp()
subApp() at @llui/dom/escape-hatch is the same primitive under its old name, kept as a
deprecated alias. It required a reason and was framed as an escape hatch for third-party
60fps layers — correct friction there, wrong for the thirteenth copy button. New code
writes island(); subApp() returns a Renderable array, so drop the spread when you
migrate.