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.
+------------------------------+
| ONE STATE MACHINE |
| states Β· events Β· context |
| pure behavior β no render |
+---------------+--------------+
| connect() β onPress Β· role Β· describedBy
+---------------+---------------+
v v v
+-----------+ +-----------+ +-----------+
| React DOM | | Native | | TUI |
| β onClick | |β Pressable| | β keypress|
| + aria-* | | + a11y | | + cells |
+-----------+ +-----------+ +-----------+
same behavior, byte-for-byte β only the render differs
Status: experimental. The engine (
packages/core) is stable and tested. The target bridges are NOT production-ready yet.This is an in-progress exploration.
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) | 7.2 M | 897 K | n/a α΅ |
| Memory / machine, 2-field (KB) | 3.6 | 3.6 | 9.1 |
| Memory / machine, 64-field (KB) | 4.1 | 4.1 | 134 |
β ~8Γ XState's throughput, on par with XState for memory but at least 3Γ 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:
AGNOSTIC SUBSTRATE
π« βοΈ --> π§ --> π --> β¨
+--------------+ +--------------+ +--------------+ +--------------+
| dunky | | machine | | binding | | behavior |
| | | | | | | |
| the engine | | states + | | neutral wire | | live feature |
| that runs | | events + | | agnostic | | on DOM / |
| machines | | logic | | events/attrs | | TUI / RN |
+--------------+ +--------------+ +--------------+ +--------------+
powers decides connects appears
- 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.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