Comparison
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 is built around two
things they aren’t: bindings that assume no platform and performance under heavy fan-out.
Shared baseline
Section titled “Shared baseline”| Capability | Zag | XState | Dunky |
|---|---|---|---|
| States / transitions / guards | ✅ | ✅ | ✅ |
Guard combinators (and/or/not) | ✅ | ✅ | ✅ |
entry / exit | ✅ | ✅ | ✅ |
| Conditional actions | choose | choose | oneOf |
| Effects with cleanup | effects | invoked callbacks | effects |
| Computed / derived state | ✅ | ✅ | ✅ |
Timed transitions (after) | ✅ | ✅ | ✅ |
| Watch (react to a data change) | watch | via always | watch |
| Per-platform late binding | ✅ | via .provide() | ComponentEffect |
| Coarse subscription | subscribe | subscribe | subscribe |
What’s different
Section titled “What’s different”The single cause underneath all the differences is how each engine holds a machine’s data.
| Zag | XState | Dunky | |
|---|---|---|---|
| Data model | reactive cell per field | immutable snapshot per event | one plain object, mutated in place |
| Fine-grained selection | host’s job | host’s job | select |
| Bindings | DOM/ARIA prop-getters | manual (state + send) | abstract, normalized per target |
| External props in the machine | prop() inside the machine | host data via input | props live at the edge only |
| Serializable snapshot | no built-in | getPersistedSnapshot() | no built-in |
| Nested / hierarchical states | yes | yes | flat |
| Parallel states | none, by design | yes | compose (peer machines, no shared bus) |
| Spawned child actors | none, by design | yes | none, by design |
XState allocates a new immutable snapshot on every transition. That’s the right trade for time-travel and serialization, but it taxes the hot path. Dunky mutates in place behind a value-deduping notifier, so a transition allocates nothing and an unchanged field wakes no observer.
Zag pioneered the headless component-as-machine idea, but its bindings are DOM-shaped (the
prop-getters emit onClick, aria-*, …) and it holds one reactive cell per context field.
Dunky keeps the machine sealed: one plain object mutated in place, with the host kept out at the edge. That’s the right trade for dense UI work. It gives up time-travel and actors, and in return the same machine runs fast with thousands of them running.
Deeper look
Section titled “Deeper look”Take a controlled toggle: a defaultOpen prop that seeds the initial state, and an onOpenChange
callback the host wants called when it flips. Watch where each engine puts those two.
In XState the prop and the callback ride into the machine: both flow through input into
context, and the machine’s own actions read them from there.
import { setup, createActor, assign } from 'xstate'
const toggle = setup({ actions: { flip: assign({ open: ({ context }) => !context.open, }), notify: ({ context }) => context.onOpenChange?.(context.open), },}).createMachine({ context: ({ input }) => ({ open: input.defaultOpen, onOpenChange: input.onOpenChange, }), on: { toggle: { actions: ['flip', 'notify'], }, },})
const actor = createActor(toggle, { input: { defaultOpen: true, onOpenChange: open => console.log(open) },})In Zag the core is headless too, but the host still reaches in: props are read inside the machine
through the prop() helper, and each context field is its own bindable reactive cell:
import { createMachine } from '@zag-js/core'
const toggle = createMachine({ props({ props }) { return { defaultOpen: false, ...props } }, context({ bindable, prop }) { return { open: bindable(() => ({ defaultValue: prop('defaultOpen'), onChange: open => prop('onOpenChange')?.({ open }), })), } }, on: { toggle: { actions: ['flip'] } }, implementations: { actions: { flip: ({ context }) => context.set('open', o => !o), }, },})In Dunky the machine has no props argument at all, it’s pure state → state. The
prop and the callback live on the connector, at the edge:
import { machine, connector } from '@dunky.dev/state-machine'
const toggle = machine({ initial: 'closed', context: {}, states: { closed: { on: { toggle: { target: 'open' } } }, open: { on: { toggle: { target: 'closed' } } }, },})
// `onPress` is an abstract binding; normalize() maps it to onClick / onPress per targetconst connect = ({ state, send }) => ({ isOpen: state === 'open', triggerProps: { onPress: () => send({ type: 'toggle' }), },})
// reaction: fires the prop-callback from outside the machine when the value changesconnect.reactions = [ [toggle => toggle.matches('open'), (open, props) => props.onOpenChange?.({ open })],]
const toggleConnection = connector(toggle, connect, props)In XState and Zag the machine definition references the host: its transitions, context, and
actions can read input / prop(...), so the machine is coupled to “a host with these props
exists.” In Dunky the machine definition (closed/open + toggle) has zero references to
props. It can’t read them; they’re not in scope. Props live only on the connector, at the edge.
So what is the value?
Section titled “So what is the value?”The machine becomes a pure (state, event) → state function, identical on every platform, with all
the host coupling quarantined to one swappable edge. That buys you four things cleanly:
- 🌍 Runs anywhere. Nothing platform-specific is inside the machine, so the same one drives web, native, or a headless test untouched; only the thin edge layer changes per target.
- 🧪 Trivial to test. Send an event, assert on the state. No host to mock, no props to supply, no framework to spin up. Just the behavior.
- 🔄 No stale callbacks. Swap a callback and the new one fires next time; the machine keeps its state through prop changes because props were never part of it.
- 🧠 Easy to reason about. The machine is only “what states exist and what flips them.” Everything the host wants (configured values, callbacks) lives entirely at the edge, in separate files.
The honest trade-off
Section titled “The honest trade-off”We’ll be straight: the speed comes from mutating in place, and that costs you things:
No time-travel or persistence. Mutating in place means there’s no history to replay and no snapshot to serialize. Spawned actors and nested states are out too. If you’re modeling full application state (undo/redo, save-and-restore, deep actor trees), reach for XState; that’s exactly what it’s built for, and it’s excellent at it.
Dunky is for UI behavior, at scale. Buttons, tooltips, dialogs, cells: hundreds of them live on one screen, the same machine running on web and native. That’s the job a sealed, mutate-in-place kernel does best: portable, and fast where it counts.
Performance
Section titled “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). In practice that’s up to ~8× the event throughput of the alternatives, flat memory.
See the benchmark for methodology and full tables.