Skip to content

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.

CapabilityZagXStateDunky
States / transitions / guards
Guard combinators (and/or/not)
entry / exit
Conditional actionschoosechooseoneOf
Effects with cleanupeffectsinvoked callbackseffects
Computed / derived state
Timed transitions (after)
Watch (react to a data change)watchvia alwayswatch
Per-platform late bindingvia .provide()ComponentEffect
Coarse subscriptionsubscribesubscribesubscribe

The single cause underneath all the differences is how each engine holds a machine’s data.

ZagXStateDunky
Data modelreactive cell per fieldimmutable snapshot per eventone plain object, mutated in place
Fine-grained selectionhost’s jobhost’s jobselect
BindingsDOM/ARIA prop-gettersmanual (state + send)abstract, normalized per target
External props in the machineprop() inside the machinehost data via inputprops live at the edge only
Serializable snapshotno built-ingetPersistedSnapshot()no built-in
Nested / hierarchical statesyesyesflat
Parallel statesnone, by designyescompose (peer machines, no shared bus)
Spawned child actorsnone, by designyesnone, 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.

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 target
const connect = ({ state, send }) => ({
isOpen: state === 'open',
triggerProps: {
onPress: () => send({ type: 'toggle' }),
},
})
// reaction: fires the prop-callback from outside the machine when the value changes
connect.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.

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.

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.

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.