Define a behavior once. Render it anywhere.
A UI component is really two things tangled together: behavior and render. Dunky splits them. You describe behavior as a plain TypeScript state machine that knows nothing about the environment and a thin per-substrate layer plugs it into a runtime.
The same machine drives any render in a JS runtime. Same states, same transitions, same accessibility intent. Only the render differs.
This project was inspired by Zag, which pioneered the
component-as-a-headless-machine approach. Zag is agnostic about which framework
renders the DOM, but it still assumes a DOM exists. Dunky takes that one step
further: it assumes nothing about the environment. The machine is a pure
behavioral kernel with no environment touchpoints, every place behavior meets the
platform ā a keydown listener, a timer, a focus ā is pushed to the per-target
layer (the view's effects.ts). So the same machine runs unchanged on the DOM,
React Native, or any other JS runtime.
The hard case is many machines reacting to many events inside one frame budget ā things like a trading terminal with live tickers, a monitoring wall, a canvas board, a game HUD. There the cost of each transition and the memory per machine, multiplied by thousands, is what decides whether you hold the frame. The engine is built for it:
| At scale (thousands of machines) | Dunky | XState | Zag |
|---|---|---|---|
| Event throughput (ops/s) | 11.6 M | 1.6 M | n/a įµ |
| Memory / machine, 2-field (KB) | 3.9 | 3.6 | 8.9 |
| Memory / machine, 64-field (KB) | 4.4 | 4.1 | 134 |
ā ~7Ć XState's throughput, on par with XState for memory but at least 2Ć lighter than Zag ā and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). įµ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the benchmark README.
ā¶ Try the live benchmark demo ā watch all three engines run in your browser.
The machine's behavior flows out through a few thin layers until it reaches real elements ā the left two are agnostic, the right three are per-target:
- core ā the state-machine engine. Pure behavior: states, transitions, context, effects. Knows nothing about a renderer.
- connector ā turns machine state into agnostic bindings and keeps that view in sync as the machine changes.
- normalize ā per target, translates those bindings into real props
(
onPressāonClickon web / aPressablehandler on RN). Always runs. - effects ā per target, the platform listener that the machine can't own
itself (a DOM
keydown, an RNBackHandler). - view ā the per-target render that spreads the normalized props onto the actual elements.
The full layered model and the "the machine never sees props" rule are in:
ARCHITECTURE.mdā the big-picture map and the layered model.packages/core/README.mdā the state machine engine and its full API.benchmark/README.mdā what's measured, the methodology, and results vs. XState & Zag.ACCESSIBILITY.mdā the external specs every package answers to.AGENTS.mdā the contributor / agent contract.
Dunky stands on the shoulders of the amazing libs:
- XState ā for the disciplined statechart model: queued run-to-completion transitions, guards, entry/exit, the rigor of treating UI as a state machine in the first place.
- Zag ā for proving the headless, framework-agnostic component-as-a-machine approach.
The engine here is an independent implementation ā its own kernel, its own state-machine runtime ā built around one bet those libraries aren't: that behavior should run with no environment assumption at all, fast enough to drive thousands of machines at once.
MIT Ā© Ivan Banov