Skip to content

States

States here are flat — no nesting, no parallel regions inside a single machine. That constraint keeps machines light and readable. The first worry is usually: won’t my state count explode?

It doesn’t have to. The explosion only happens if you fold independent axes onto the single state axis. A flat state should encode exactly one axis of control flow. Everything that would multiply it belongs elsewhere:

You have…Don’t…Do…
A value derived from datamake a state per item → N nodeskeep inputs in context, derive in computed
A second independent lifecyclemultiply into the first machine’s states → N×Mrun it as a peer with compose
A category over many statesmatches('a') || matches('b') || …tag the states, query with hasTag

A combobox has an open/closed popup and a highlighted item and a filtered list. Naively that’s open/closed × N items × filtered/not. In practice:

machine({
initial: 'idle',
context: { query: '', items: ALL_ITEMS, activeIndex: -1 },
computed: {
filtered: $ => $.context.items.filter(i => i.label.includes($.context.query)),
highlighted: $ => $.computed.filtered[$.context.activeIndex] ?? null,
},
states: {
idle: { on: { focus: { target: 'open' } } },
open: {
on: {
type: act($ => ({ query: $.event.value, activeIndex: 0 })),
moveDown: act($ => ({ activeIndex: $.context.activeIndex + 1 })),
moveUp: act($ => ({ activeIndex: $.context.activeIndex - 1 })),
close: { target: 'idle' },
},
},
},
})

Two state nodes. highlighted recomputes lazily — the transitions scale with the kinds of move, not the list length.

Adding a loader: compose instead of nesting

Section titled “Adding a loader: compose instead of nesting”

If the combobox also has an async loader (idle → loading → loaded → error), don’t fold it into the popup machine:

// 2 + 4 = 6 nodes total — additive, not multiplicative
const combobox = compose({ popup: popupMachine, loader: loaderMachine })

See Peer machines for the full compose API.

When the view wants to ask “is the list visible?” across several states, tag them:

states: {
idle: {},
open: { tags: ['expanded'] },
filtering: { tags: ['expanded'] },
}
m.hasTag('expanded') // true in 'open' or 'filtering' — one query, no OR-chain