Skip to content

Peer machines

When a component has two independent axes of state — say a popup that’s open/closed and a submenu that’s shown/hidden — each axis is its own machine. compose runs them as one unit (orthogonal regions, without nesting):

import { machine, compose } from '@dunky-dev/state-machine'
const popup = machine({
/* closed / open */
})
const submenu = machine({
/* none / shown */
})
const combobox = compose({ popup, submenu })
combobox.start() // starts every member
combobox.stop() // stops all + disposes sync and combine
// members stay independent — drive and read each directly
popup.send({ type: 'focus' })
submenu.send({ type: 'open' })

React when any member changes. Coarse — it wakes on any change to any member; read what you need inside:

combobox.sync(() => {
if (popup.matches('closed')) submenu.send({ type: 'close' })
})

Derive one value across members. Re-evaluates on any member change; fires only when the combined value changes:

const view = combobox.combine(() => ({
open: popup.matches('open'),
sub: submenu.state,
}))
view.value // { open: true, sub: 'shown' }
view.subscribe(render)

Both sync and combine are auto-disposed on combobox.stop().

Composing avoids the state explosion of folding multiple axes into one machine:

// popup: 2 states, loader: 4 states
const combobox = compose({ popup, loader })
// → 2 + 4 = 6 nodes total, not 2 × 4 = 8

Members are independent peers — a send goes to one member, not broadcast. Cross-region behavior is expressed explicitly via sync. That’s the deliberate trade: simpler than nested/parallel statecharts, at the cost of a shared event bus.

See States for the full decision guide on when to use computed, compose, or tags.