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 data | make a state per item → N nodes | keep inputs in context, derive in computed |
| A second independent lifecycle | multiply into the first machine’s states → N×M | run it as a peer with compose |
| A category over many states | matches('a') || matches('b') || … | tag the states, query with hasTag |
Example: combobox
Section titled “Example: combobox”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 multiplicativeconst combobox = compose({ popup: popupMachine, loader: loaderMachine })See Peer machines for the full compose API.
Categories: tags
Section titled “Categories: tags”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