@dunky.dev/state-machine
v0.2.0
Published
A tiny, **renderer-agnostic state-machine engine** for building UI component logic once and running it anywhere. It owns _behavior_, states, transitions, side-effects, derived state, and knows nothing about the render environment.
Readme
@dunky.dev/state-machine
A tiny, renderer-agnostic state-machine engine for building UI component logic once and running it anywhere. It owns behavior, states, transitions, side-effects, derived state, and knows nothing about the render environment.
It's pure JavaScript: it runs in any JS runtime (browser, Node, the React Native JS thread), but not in native platform code (e.g. Swift/Kotlin).
import { machine, act } from '@dunky.dev/state-machine'
const toggle = machine({
initial: 'inactive',
context: { count: 0 },
states: {
inactive: {
on: {
flip: { target: 'active', actions: act($ => ({ count: $.context.count + 1 })) },
},
},
active: {
on: { flip: { target: 'inactive' } },
},
},
})
toggle.start()
toggle.send({ type: 'flip' })
toggle.state // 'active'
toggle.context.count // 1That machine is the whole behavior. To render it, a target adds two thin steps, and the machine itself never changes:
connect()turns machine state into logical bindings,onPress,role,describedBy.normalizetranslates those to real props per platform,onClick+aria-*on web,Pressableprops on React Native.
The trade-off
This is a focused engine, not a do-everything statechart. It leaves out, on purpose:
- Nested / parallel / hierarchical states, flat states + composition instead.
- Serializable-snapshot features, time-travel, persistence, a visual inspector.
- Spawned child machines / actors.
Need those? Reach for XState. Driving many lightweight UI machines you never serialize? You're not paying for capabilities you don't use. The full side-by-side, what's shared, what differs, and the measured numbers, is below.
How it compares
Anyone who has reached for XState or
Zag will feel at home, same statechart vocabulary
(states, transitions, guards, actions, effects), same headless
philosophy. Those libraries are excellent; this one exists for two things they
aren't built around: no environment assumption (Zag is framework-agnostic but
presumes a DOM) and performance under heavy fan-out.
Shared baseline:
| Capability | Zag | XState | state-machine |
| ------------------------------ | ---------- | ----------------- | ------------- |
| States / transitions / guards | ✅ | ✅ | ✅ |
| Guard combinators (and/or/not) | ✅ | ✅ | ✅ |
| entry / exit | ✅ | ✅ | ✅ |
| Conditional actions | choose | choose | oneOf |
| Effects with cleanup | effects¹ | invoked callbacks | effects |
| Computed / derived | ✅ | ✅ | ✅ |
| Timed transitions (after) | ✅ | ✅ | ✅ |
| Watch (react to a data change) | watch | via always | watch |
| Per-platform late binding | ✅ | via .provide() | effects.ts |
- ¹
effectsis the same idea in Zag and here, but Zag's effects receive ascope(a DOM) and reach for it; ours receive no environment.
The differences: The single cause underneath all of them is how each engine holds a machine's data. This lib keeps context as one plain object per machine, mutated in place (copied once at construction; its identity never changes) + a tiny notifier, no per-field reactive cell (Zag), no immutable snapshot per event (XState).
| What's different | Zag | XState | state-machine |
| ------------------------------- | ---------------------------- | ---------------------------- | ------------------------------------- |
| State selection | ❌ host framework does it | ⚠️ actor.select (coarse) | 🟢 select (fine-grained) |
| Runs with no host framework | ❌ needs a framework | ⚠️ statechart yes | 🟢 yes |
| Flat-ish state memory | ❌ a reactive cell per field | 🟢 plain snapshot | 🟢 plain context (one object/machine) |
| Data model | reactive cell per field | immutable snapshot per event | one plain object, mutated in place |
| Serializable snapshot¹ | ❌ state too scattered | 🟢 the actor model | ⚠️ no built-in |
| Nested / hierarchical states | ❌ by design² | ✅ | ❌ flat |
| Parallel state | ❌ by design² | ✅ | ⚠️ compose (peers, no shared event) |
| Spawned child machines / actors | ❌ by design² | ✅ | ❌ by design² |
- XState allocates a serializable snapshot on every transition, and it taxes the hot path. state-machine mutates in place instead.
- Zag can run framework-free but presumes a host DOM framework. state-machine owns its reactivity internally, extending the same idea onto any JS environment (DOM, React Native, TUI, WebGL, ...).
- ¹ Serializable snapshot, only XState ships it (actor model). state-machine can add one (context is one plain object); Zag can't easily (state is scattered React hook cells).
- ² ❌-by-design keep machines light-weight, avoid the heavy statechart concepts.
Performance
Dunky is built for density × frequency, many machines reacting to a high-frequency stream inside one frame budget (trading terminals, canvas boards, monitoring walls, game HUDs). Context is one plain object mutated in place behind a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers.
In practice that's up to ~8× the event throughput of the alternatives, flat memory as context grows wide, and surgical re-renders that wake only the rows that actually changed.
See the benchmark → for what's measured, how, and the full per-scenario tables vs. XState and Zag.
The machine never sees props
A machine here is pure behavior, it has no props argument and no prop()
accessor, so the same machine runs byte-for-byte identically on every target.
This is the engine's defining rule, and the one place it diverges from Zag/XState
(whose machines read props directly). The full rationale + the layered model live
in ARCHITECTURE.md;
the engine-level summary: every job a prop does lands at the edge, never the
machine:
| A prop that… | …goes here |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
| configures behavior | seeded into context once (and updated via setContext) |
| fires a callback | a reaction on the connector (see Reactions) |
| is controlled state | resolved into the initial state before machine() is built |
API at a glance
| Export | What it is |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| machine(config) | build a service (stopped); .start() / .stop() / .send() / .state / .context / .computed / .subscribe / .select / .onStart / .onStop |
| setup.infer().createMachine(config) | author a config from a literal, infers State / Context / Event, no type args; named guards/actions/effects/delays stay loose strings (lightweight path) |
| setup.as<Ctx,Ev>().config(registries).createMachine(config) | author a config with every guard/action/effect/delay name compile-checked + autocompleted against the registry keys, a typo is a compile error (checked path) |
| connector(service, connect, props) | live, memoized, subscribable view snapshot: .snapshot / .subscribe / .select / .setProps (prop-callbacks wire automatically) |
| makeReaction<…>() | inference helper for a connector reaction, fixes the machine generics once, infers each reaction's selector→callback Value (see Reactions) |
| compose({ a, b }) | run several machines as one (orthogonal regions): bundled start/stop + .sync() + .combine() |
| createStore(initial, build?) | a tiny reactive store (plain value + listeners) for cross-instance singleton state (outside any one machine) |
| and / or / not | guard combinators |
| act(...patches) | write-sugar: a context-writing action (one or many patches, applied in order). Slots in any actions / entry / exit list |
| oneOf(...branches) | conditional action: variadic { guard?, actions } branches, first passing wins (guardless = fallback) |
| MACHINE_INIT | the synthetic event fired when effects/watchers boot on start() |
| Types | Machine, MachineConfig, TransitionConfig, Guard, Action, Effect, Delay, Selection, Connect, Store, StateNode, EventBindings, AttrBindings, … |
Lifecycle
machine(config) returns a service that is built but not running. The
caller controls when it boots:
const m = machine(config)
m.start() // boot it (effects + watchers begin)
m.stop() // tear down (cleanups run); restartable with start()The lifecycle lives on the instance so every target drives it the same way:
React calls them in useEffect, a test inline. The shared teardown logic is
written once. send() still works while stopped (transitions are pure state);
only side-effects are gated by start/stop.
onStart / onStop let an outer layer hang start/stop-scoped work off the
machine's lifecycle without the machine knowing what it is, this is how the
connector wires its reactions
on boot and tears them down on stop. They fire on every start/stop (a machine
can restart), so listeners must be idempotent; onStart also runs immediately if
the machine is already running, so a late registrant never misses it. Each
returns an unregister.
const offStart = m.onStart(() => {
/* start-scoped wiring */
})
const offStop = m.onStop(() => {
/* teardown */
})Tip: author configs with
setup. Writing a config as a bare object gives weaker type-checking than authoring it throughsetup, which applies the fullTransitionConfigconstraint at the definition site. Two paths off one call:import { setup, machine } from '@dunky.dev/state-machine' // lightweight, infers State / Context / Event from the literal, names loose: const cfg = setup .infer() .createMachine({ initial: 'closed', context: {}, states: { closed: {} } }) // checked, name a registry, then every guard/action/effect/delay reference in // the config is compile-checked + autocompleted against its keys (typo = error): const { createMachine } = setup.as<Ctx, Ev>().config({ guards: { isOpen: ({ context }) => context.open }, }) const checked = createMachine({ initial: 'closed', context: { open: false }, states: { closed: { on: { toggle: { target: 'open', guard: 'isOpen' } } }, open: {} }, }) const m = machine(cfg)
Context, reactive data
context is the component's data. It's read as a plain property and
written through setContext (a single, batched entry point):
const m = machine({
initial: 'idle',
context: { name: 'Ada', age: 36 },
states: {
idle: {
on: {
birthday: { actions: $ => $.setContext({ age: $.context.age + 1 }) },
},
},
},
})
m.context.name // 'Ada'
m.send({ type: 'birthday' })
m.context.age // 37States & Transitions
Each state lists the events it responds to under on. A transition can change
state (target), run actions, or both:
const light = machine({
initial: 'green',
context: {},
states: {
green: { on: { next: { target: 'yellow' } } },
yellow: { on: { next: { target: 'red' } } },
red: { on: { next: { target: 'green' } } },
},
})
light.start()
light.send({ type: 'next' }) // green → yellowAny-state events go in the top-level on (a per-state on wins over it):
on: { reset: { target: 'a' } }, // works from ANY stateTags group states so consumers ask about a category, not a name:
states: {
closed: {},
opening: { tags: ['visible'] },
open: { tags: ['visible'] },
}
m.hasTag('visible') // true while in opening OR open
m.matches('open') // exact-state checkEvents are queued (run-to-completion). If an action sends another event, it waits until the current transition fully finishes, no re-entrancy surprises:
states: {
a: { on: { go: { target: 'b', actions: [({ send }) => send({ type: 'auto' })] } } },
b: { on: { auto: { target: 'c' } } }, // 'auto' processes AFTER the machine settles in 'b'
c: {},
}Guards, gating a transition
A guard is a predicate; return false and the transition doesn't fire. It
receives { context, event, computed }:
on: { submit: { target: 'done', guard: ({ context }) => context.allowed } }Fallthrough, give an event an array of transitions; the first whose guard passes wins:
on: {
tick: [
{ guard: ({ context }) => context.n >= 10, target: 'max' },
{ guard: ({ context }) => context.n > 0, target: 'some' },
{ target: 'zero' }, // no guard = fallback
],
}Named guards + combinators (and / or / not):
import { and, not } from '@dunky.dev/state-machine'
machine({
// ...
states: {
idle: { on: { toggle: { target: 'busy', guard: and('isOpen', not('isLocked')) } } },
},
implementations: {
guards: {
isOpen: ({ context }) => context.open,
isLocked: ({ context }) => context.locked,
},
},
})Actions, fire-and-forget side-effects
Actions run on a transition, in order, getting
{ context, setContext, event, send, computed }. Inline or named:
on: {
save: {
actions: [
$ => $.setContext({ saved: true }),
'notify', // a named action from implementations.actions
],
},
}act(...) is write-sugar for the most common action, setting context. It
drops the $ => $.setContext(...) wrapper, so the patch reads as data, and takes
one or many patches (applied in order; a later patch fn sees earlier writes):
import { act } from '@dunky.dev/state-machine'
on: {
save: {
actions: [
act({ saved: true }), // ≡ $ => $.setContext({ saved: true })
'notify',
],
},
// one or many fields, static or derived:
bump: { actions: act({ touched: true }, $ => ({ n: $.context.n + 1 })) },
}act only WRITES, target / guard stay on the transition object. It returns
a normal action, so it slots in any actions / entry / exit list or a oneOf
branch.
entry / exit run when a state is entered / left, handy for behavior that
should run on any way in or out, without repeating it on each transition:
states: {
open: {
entry: ['focusFirstItem'], // on enter
exit: ['restoreFocus'], // on leave
on: { close: { target: 'closed' } },
},
closed: {},
}oneOf picks one branch of actions by guard (the action analog of
fallthrough):
oneOf(...) is variadic, its branches are plain { guard?, actions } objects
(the same shape as a transition, minus target); actions may be a single action
or a list. A guardless branch is the fallback (put it last):
import { oneOf } from '@dunky.dev/state-machine'
actions: [
oneOf(
{ guard: 'isMobile', actions: 'lockScroll' },
{ guard: 'isDesktop', actions: 'dimBackground' },
{ actions: 'noop' }, // guardless = fallback
),
]Effects, side-effects with cleanup
An effect runs when a state is entered and returns an optional cleanup
that runs when the state is left. Setup and teardown share one closure, so
whatever an effect starts on enter is torn down by the exact cleanup that
captured it (something plain entry/exit can't do without manual bookkeeping):
states: {
open: {
// subscribe to a store while open; unsubscribe on exit. No platform, no props
//, a pure effect, so it lives right here in the config.
effects: [
({ context, send }) =>
store.subscribe(() => {
if (store.get().openId !== context.id) send({ type: 'close' })
}),
],
on: { close: { target: 'closed' } },
},
closed: {},
}The current state's effects boot on start() (the initial state's, on a fresh
machine); all active cleanups run on stop(). Effects can be named and resolved
from implementations.effects (so they're reusable / overridable) or written
inline.
Where does a side-effect live?, the two homes
Not every side-effect belongs in the config. The deciding question is does it touch the platform (a DOM listener, a native API) and does it need props (the machine never sees props). That gives two homes:
Is the effect props-free AND platform-free?
|
+-- Yes -> a plain machine effect, in the config `effects`
| (e.g. a store subscription -- same on every target,
| runs in the machine scoped to a state)
|
+-- No -> a ComponentEffect in the target's effects.ts -- the VIEW owns it
(prop-aware and/or platform-specific: a DOM keydown for Escape,
RN BackHandler; passed to `useMachine`, one `useEffect` per entry)| Home | Owns the lifecycle | Touches platform | Reads props |
| -------------------------- | ------------------ | ---------------- | ----------- |
| config effects: { … } | machine | no | no |
| ComponentEffect (target) | the view | yes | yes/no |
A config effect is props-free and platform-free, so it's identical on every
target, a store subscription is the canonical case. Anything that touches the
platform (a DOM keydown for Escape, the RN BackHandler) or reads a prop
(closeOnEscape) lives in the target's effects.ts as a ComponentEffect,
passed to useMachine (which runs one useEffect per entry). The machine never
sees it; the effect just send()s a plain event the machine already handles.
Computed, derived data
Computeds are values derived from context (or other computeds). Lazy and memoized: a computed recalculates only when an input it actually read changes.
const m = machine({
initial: 'idle',
context: { items: ['a', 'b'] },
computed: {
count: ({ context }) => context.items.length,
isEmpty: ({ computed }) => computed.count === 0, // derives from another computed
},
states: { idle: {} },
})
m.computed.count // 2
m.computed.isEmpty // falseComputeds are available everywhere context is, guards, actions, effects
({ ..., computed }), and on the machine itself (m.computed.x).
after, timed transitions
A state can transition automatically after a delay. The timer is scoped to
the state: scheduled on enter, auto-cancelled if the state is left (or
stop() is called) before it fires.
const m = machine({
initial: 'closed',
context: { openMs: 200 },
states: {
closed: { on: { hover: { target: 'opening' } } },
opening: { after: { openDelay: { target: 'open' } } }, // wait, then open
open: { after: { 1500: { target: 'closed' } } }, // auto-dismiss after 1.5s
},
implementations: {
delays: { openDelay: ({ context }) => context.openMs }, // named, dynamic delay
},
})A delay key is a number of ms (1500) or a named delay resolved from
implementations.delays (which can read context/computed, so it's dynamic).
An after entry is a normal transition: it can target, run actions, and use
guard fallthrough. A pure wait-then-advance state is "sleep":
states: {
flash: { after: { 300: { target: 'idle' } } }, // show for 300ms, then return
}watch, react to data changes
Where after reacts to time and effects react to state, watch reacts to
data: run actions whenever a context (or computed) field changes, in any
state, for the machine's whole lifetime.
const m = machine({
initial: 'idle',
context: { query: '' },
watch: {
query: ['runSearch'], // whenever `query` changes → runSearch
},
states: { idle: {} },
implementations: {
actions: { runSearch: ({ context, send }) => send({ type: 'search', q: context.query }) },
},
})The declarative form of "subscribe to a field and run something." Watchers start
on start(), clean up on stop(), and fire only when the watched field's value
actually changes (not on setup). Watch actions obey the same
run-to-completion rule as events: they're queued and run after the
transition that changed the field fully settles (never mid-transition), so they
always observe the whole write, and a watcher writing context can't re-enter the
transition in flight. Use watch for side-effecting reactions; for a derived
value, reach for computed instead.
Subscriptions, observing changes
Coarse subscribe wakes on any change (what a useSyncExternalStore
bridge uses):
const off = m.subscribe(() => rerender()) // fires on any state/context change
off() // unsubscribeIt fires on any setContext or state change. A computed change is covered
transitively (a computed only changes when context it reads changes), so "any
change" holds in practice.
Fine-grained select narrows to a slice and fires only when that slice's
value changes:
// a single named field (typed + autocompleted):
m.select.context('count').subscribe(n => console.log('count is now', n))
m.select.computed('isEmpty').subscribe(empty => toggle(empty))
m.select.state().subscribe(s => console.log('state →', s))
// or a derived/composite selection via a function:
const view = m.select(() => ({ open: m.matches('open'), count: m.context.count }))
view.subscribe(render, (a, b) => a.open === b.open && a.count === b.count) // optional equality
view.value // read the current selected value directlyA select re-evaluates on any machine change but only fires its listener when the
selected value actually changes, so an observer wakes only for the slice it reads.
Connector, the view boundary
A component's pure connect(snapshot) → api maps machine state into a
view-facing object (handlers + attributes a renderer spreads onto elements). The
connector keeps that mapping live and hands the renderer a stable,
subscribable snapshot:
import { connector } from '@dunky.dev/state-machine'
const connect = ({ state, send }) => ({
isOpen: state === 'open',
triggerProps: { onPress: () => send({ type: 'toggle' }) },
})
const c = connector(m, connect, /* initial props */ {})
c.snapshot // memoized api; identity is stable until something changes
c.subscribe(rerender) // coarse, wake the view
c.select // forwarded fine-grained path
c.setProps(newProps) // props are a reactive input; recomputes the snapshotsetProps is shallow-dedup'd: passing a fresh-but-equal props object (the
common case, a host that rebuilds props every render) is a no-op, so it won't
needlessly recompute the snapshot or wake subscribers. Only a real value change
propagates.
The connector surface is small: snapshot, subscribe, select, setProps, and
destroy (detach from the machine when discarding the connector on its own).
Prop-callbacks (reactions)
are wired automatically off the machine's own lifecycle, there's nothing to
activate by hand.
c.snapshot's identity changes only when the machine (or props) changes, so it
drops straight into React's useSyncExternalStore(c.subscribe, () => c.snapshot)
without the infinite-loop / tearing pitfalls of returning a fresh object each
read.
Why
connectreturns abstract handlers (onPress) and notonClick: core stays renderer-blind. A per-targetnormalizestep translates the agnostic bindings vocabulary (onPress,role,describedBy) into real props (onClick,aria-describedby), so the sameconnectcan target the DOM, React Native, or any other surface.
Reactions, firing prop-callbacks without the machine knowing
Because the machine never sees props,
a callback like onOpenChange can't fire from inside it. A reaction is how
the connector bridges that gap from the outside: a declared
[selector, callback] tuple that watches a value derived from machine state
and, when it changes, calls the matching prop. (Same tuple shape as a React
ComponentEffect, declare each as a named const, collect them in a list.)
import { makeReaction } from '@dunky.dev/state-machine'
// fix the machine generics once per component; `Value` is then inferred per reaction:
const reaction = makeReaction<TooltipState, TooltipContext, TooltipEvent, TooltipProps>()
const onOpenChange = reaction(
m => m.matches('open') || m.matches('closing'), // selector → Value inferred as boolean
(open, props) => props.onOpenChange?.({ open }), // callback: open is boolean, no annotation
)
connectTooltip.reactions = [onOpenChange]makeReaction(...) is just an inference helper: it returns the same
[selector, callback] tuple, but recovers the selector→callback Value link a
bare array would lose (inline, the tuple lands in Reaction<…, any> and the
callback's first arg is untyped). The raw Reaction<…> tuple type still works if
you prefer to spell it out.
The machine just transitions closed → open; it has no idea onOpenChange
exists. The connector runs the selector (tuple position 0) as a value-deduped
select(...), and when the result flips it calls the callback (position 1) with
the current props (so a swapped callback is always the one that fires). This
is the inversion from Zag, for example, which fires the same callback as an
invokeOnOpen action inside the machine, here the firing is pulled out to the
edge, which is what keeps the machine pure.
selector is always a function (no state-name shorthand): it reads whatever
it needs off the machine, m.matches('open') for a state-based reaction, or
m.context.highlightedValue for a value-based one, so a single shape covers
every reaction whether it keys off state or context.
Reactions are wired automatically, there is no activation call. The
connector hooks the machine's own lifecycle (onStart / onStop), so reactions
come alive on start() and are torn down on stop(), exactly as long as the
machine runs. The bridge only drives the machine; reactions follow for free:
useEffect(() => {
service.start() // reactions wire themselves here (connector's onStart hook)
return () => service.stop() // …and tear down here (onStop)
}, [service])Hooking the machine's lifecycle (not the connector's construction) means a restart, notably React StrictMode's mount→unmount→mount, cleanly re-establishes the reactions with no bookkeeping in the bridge.
Contrast with the target's effects: a reaction is for pure state→callback
(portable, declared once, runs on every target). Anything needing the platform
itself, a DOM keydown listener for Escape, lives in the target's effect
layer (the React bridge's ComponentEffect), which gates it and then send()s a
plain event the machine already handles.
- reaction = agnostic edge (state → callback, every target)
- target effect = platform edge (DOM/RN listeners →
send())
Composing machines
States are flat. When a component has two independent dimensions of state at
once (say a popup that's open/closed and a submenu that's shown/hidden),
each dimension is its own machine, and compose runs them as one unit
(orthogonal regions, without nested states):
import { 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; .stop() stops all + disposes the helpers below
// members stay independent, drive and read each on its own:
popup.send({ type: 'focus' })
submenu.send({ type: 'open' }) // both regions active simultaneouslycompose returns a Composition with two helpers, both auto-disposed on
stop():
// sync, a cross-region rule: react when any member changes. COARSE: it wakes on
// any change to any member (the rule reads what it needs); use it for cross-region
// coordination, not as a fine-grained per-field watcher.
combobox.sync(() => {
if (popup.matches('closed')) submenu.send({ type: 'close' })
})
// combine, one value-deduped Selection derived across members; re-evaluates on
// any member change and 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)This is the engine's answer to hierarchy/parallel statecharts: rather than
nesting states in one machine, compose independent peer machines. Each stays
individually observable (fine-grained select per region), and compose adds
only the lifecycle + coordination glue.
composevs. true parallel states. Members are independent peers: asendgoes to one member, not broadcast across regions, and there's no shared event bus. Cross-region behavior is expressed explicitly viasync. That's the deliberate trade, simpler than nested/parallel statecharts, at the cost of a shared event model.
Flat states & managing "nested" data
States here are flat, there's no hierarchy and no parallel regions inside a
single machine. That's a deliberate constraint, and the first reaction to it is
usually the same worry: won't my states explode? Take a combobox (an input
with a filtered dropdown). It feels like it has many states at once: the popup is
open or closed, and some item is highlighted (one of N), and a value may be
selected. Treat each combination as its own state and you get the product,
open/closed × N highlighted × selected/not, which blows up the moment the list
grows.
It doesn't have to, because the explosion only happens if you fold independent things onto the single state axis. A flat state should encode exactly one axis of control flow (here: is the popup open?). Everything that would multiply that axis is pushed sideways onto a different tool:
- a product of data →
computed - a second lifecycle →
compose - a grouping over states →
tags
| You have… | Don't… | Do… |
| --------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------- |
| A value derived from data (which item is highlighted) | make a state per item → N nodes | keep the inputs in context, derive it in computed |
| A second independent lifecycle running at once | multiply it into the popup states → N×M | run it as a peer with compose |
| A category over many states ("is the list showing?") | matches('a') \|\| matches('b') \|\| … | tag the states, query with hasTag |
A product of data → computed
"Which item is highlighted" isn't control flow, it's a value derived from the
query, the filtered list, and the active index. Those are context fields; the
highlighted item is a derived value, not a state per row:
machine<
'idle' | 'open',
{ query: string; items: Item[]; activeIndex: number },
Event,
{
filtered: Item[]
highlighted: Item | null
}
>({
initial: 'idle',
context: { query: '', items: ALL_ITEMS, activeIndex: -1 },
computed: {
// derived, memoized, never a state per item
filtered: $ => $.context.items.filter(i => i.label.includes($.context.query)),
highlighted: $ => $.computed.filtered[$.context.activeIndex] ?? null,
},
states: {
idle: { on: { focus: { target: 'open' } } },
open: {
on: {
// a few handlers move the cursor / filter, NOT one transition per row
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, not "one per item." highlighted is recomputed lazily and
only when an input it read changes, so the transitions scale with the kinds of
move (type / up / down), not with the list length.
A second lifecycle → compose
If a second axis is genuine control flow, e.g. an async loader that fetches
the options (idle → loading → loaded → error) running alongside the open/closed
popup, don't fold it into the popup machine (that's popupStates × loaderStates
nodes). Make it a peer region and compose the two:
const combobox = compose({ popup: popupMachine, loader: loaderMachine })
// 2 popup + 4 loader states = 6 nodes total, not 2 × 4 = 8, additive, not multiplicativeA grouping over states → tags
Even flat, a machine accumulates states, and the view often wants a category: "is the listbox visible right now?", which may be true across several states. Tagging keeps that query from scaling with the state count (see Tags):
states: {
idle: {},
open: { tags: ['expanded'] },
filtering: { tags: ['expanded'] }, // still showing the list, just narrowing it
}
m.hasTag('expanded') // true in open OR filtering, one query, no OR-chainThe throughline: flat states stay readable because the things that would have
multiplied them live elsewhere, data in computed, parallel lifecycles in
compose, categories in tags.
createStore, cross-instance singleton state
Context lives inside a machine. Some state belongs outside any one machine:
a singleton shared across instances, like "only one tooltip open at a time" or "a
single active menu in a menubar." createStore is a tiny reactive cell for
exactly that: a plain value plus a listener set.
import { createStore } from '@dunky.dev/state-machine'
const store = createStore({ count: 0 })
store.get() // { count: 0 }
store.set({ count: 1 }) // shallow-merge a patch…
store.set(s => ({ count: s.count + 1 })) // …or an updater (no-op writes don't notify)
const off = store.subscribe(s => console.log(s.count)) // fires on change, not on subscribe
off()Pass a second build argument to add named domain methods on top, no facade
boilerplate. build receives the base store, so the methods read/write through
it:
const tooltipStore = createStore({ openId: null as string | null }, s => ({
setOpen: (id: string | null) => s.set({ openId: id }),
isOpen: (id: string) => s.get().openId === id,
}))
tooltipStore.setOpen('a')
tooltipStore.isOpen('a') // trueThe store is not wired into a machine's select automatically, reading
store.get() inside a select won't re-fire when the store changes. To make a
machine react to a shared store, bridge it explicitly: subscribe to the store and
forward the change as an event the machine already handles.
const off = tooltipStore.subscribe(s => m.send({ type: 'activeChanged', openId: s.openId }))Putting it together
import { setup, machine, connector } from '@dunky.dev/state-machine'
// 1. describe behavior (agnostic), setup.infer() type-checks the literal in place.
// Pure, props-free, platform-free, so it's the lightweight path.
const disclosureConfig = setup.infer().createMachine({
initial: 'closed',
context: {},
states: {
closed: { on: { open: { target: 'open' } } },
open: { on: { close: { target: 'closed' } } },
},
})
// A platform listener, closing on the Escape key, would NOT live here:
// it touches the DOM (and is usually prop-gated), so it's a `ComponentEffect`
// in the target's effects.ts, which `send({ type: 'close' })`s the event above.
// 2. map to a view api
const connect = ({ state, send }) => ({
isOpen: state === 'open',
triggerProps: { onPress: () => send({ type: 'open' }) },
})
// 3. run it
const m = machine(disclosureConfig)
m.start()
const view = connector(m, connect, {})
view.subscribe(render)Glossary
Every term and concept in @dunky.dev/state-machine, with a one-line meaning and a link to
its full section.
| Term | Meaning |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Building blocks | |
| machine | The built service from machine(config), exposes start/stop/send/state/context/select. → |
| setup | The authoring entry point, setup.infer().createMachine(literal) (lightweight, names loose) or setup.as<Ctx,Ev>().config(registries).createMachine(config) (names compile-checked). → |
| state | One of the flat, named situations the machine can be in (it's in exactly one at a time). → |
| transition | An on entry: where an event takes the machine, optional target, guard, actions. → |
| event | The { type, … } object you send() to drive a transition. → |
| context | The machine's data: one plain object, read directly (m.context.x), written via setContext. → |
| setContext | The single, batched entry point for writing context (shallow-equal deduped). → |
| send | Dispatch an event to the machine; events run to completion (see below). → |
| Transitions & actions | |
| guard | A predicate that gates a transition, return false and it doesn't fire. → |
| and/or/not | Guard combinators for composing named guards. → |
| fallthrough | An array of transitions for one event; the first whose guard passes wins. → |
| action | A fire-and-forget side-effect run on a transition, in order, gets {context,setContext,event,send,computed}. → |
| act | Write-sugar returning a context-writing action, act({ field: value }) instead of the setContext wrapper. → |
| oneOf | Conditional action: variadic { guard?, actions } branches, first passing wins (the action analog of fallthrough). → |
| entry / exit | Actions run when a state is entered / left (any path in or out). → |
| run-to-completion | Events queue: an event send()-ed from inside an action waits until the current transition finishes, no re-entrancy. → |
| Time, data & derivation | |
| after | A timed transition, fire after a delay while in a state; auto-cancelled on exit. → |
| delay | An after key: a number of ms, or a named delay from implementations.delays (can read context). → |
| watch | Run actions whenever a context/computed field changes, in any state, for the machine's lifetime. → |
| computed | A lazy, memoized value derived from context (or other computeds); recomputes only when a read input changes. → |
| Effects & the platform seam | |
| effect | A side-effect with cleanup, scoped to a state: runs on enter, its returned cleanup runs on exit. → |
| implementations | The named registry on a config, guards / actions / effects / delays referenced by string. → |
| The view boundary | |
| connect | A pure function mapping a machine snapshot → the view-facing api (handlers + attributes). → |
| connector | Keeps connect live: memoizes the snapshot, makes props a reactive input, wires reactions. → |
| snapshot | The memoized view api the connector serves, stable identity until the machine or props change. → |
| setProps | Push new props into the connector (a reactive input; shallow-dedup'd). → |
| reaction | A [selector, callback] tuple that fires a prop-callback from outside the machine on a value change. → |
| makeReaction | Inference helper for a reaction tuple, recovers the selector→callback Value type. → |
| bindings | The agnostic event/attr vocabulary connect speaks, onPress, role, describedBy. → |
| normalize | The per-target step translating bindings → real props (onPress → onClick; aria-* on web). → |
| Observing changes | |
| subscribe | Coarse observation, fires on any state/context change (what a useSyncExternalStore bridge uses). → |
| select | Fine-grained observation, narrows to a slice, fires only when that value changes. → |
| selection | What select(...) returns: a value-deduped view with .value + .subscribe. → |
| Composition & scale | |
| compose | Run several peer machines as one unit (orthogonal regions), the answer to "nested/parallel" without nesting. → |
| composition | What compose(...) returns: bundled start/stop plus sync + combine. → |
| sync | A coarse cross-region rule on a composition, runs when any member changes. → |
| combine | A value-deduped selection derived across composition members. → |
| tags | Labels on states so consumers query a category (hasTag('visible')) instead of a name. → |
| hasTag | Check whether the current state carries a tag. → |
| matches | Exact-state check, m.matches('open'). → |
| createStore | A tiny reactive cell (value + listeners) for singleton state outside any one machine. → |
| store | What createStore(...) returns: get / set / subscribe (+ optional domain methods). → |
| Lifecycle | |
| start / stop | Boot / tear down the machine, effects, watchers, and reactions begin / clean up. → |
| onStart / onStop | Hang start/stop-scoped work off the machine's lifecycle (how the connector wires reactions). → |
| MACHINE_INIT | The synthetic event fired when effects/watchers boot on start(). → |
| Cross-cutting concepts | |
| the machine never sees props | The defining rule: a machine is pure behavior; props live only at the edge. → |
| the edge | Where props/platform meet the machine, the connector (props, reactions) + the target's effects.ts (platform listeners). → |
| context ownership | The context memory model: one plain object per machine, copied from the config at construction, mutated in place, its identity never changes. → |
