Divergence map
Where the six implementations genuinely differ (contract-level decisions a new adapter must make) versus where they are line-for-line identical (framework idiom, safe to copy verbatim).
Line-for-line identical (copy verbatim)
These pieces have no framework-specific content at all — they read the same regardless of which adapter you open. A new adapter can start here and get it right without inventing anything:
refs.tsin solid/vue/svelte/vanilla — 12 lines each, a plain{ current: refs }box withget/setby key. React and preact wrap the same box in auseRefpurely for React-idiom identity stability, not because the semantics differ.- The
action/guard/effect/chooseclosures inside everyuseMachine/constructor — the exact same body (resolve function-or-array of action keys, look upmachine.implementations.actions[key], warn if missing, call each withgetParams()) appears in react, solid, vue, svelte, preact, and vanilla with only cosmetic differences (arrow function vs class method,this.prefix in vanilla). - The
statebindable'sonChangehandler — the exit-effects → exit-actions → transition-actions → entry-effects → (ifprevState === INIT_STATE:machine.entry+machine.effects) → entry-actions sequence, including thecallAll(existing, cleanup)composition for effect cleanups keyed by state path, is identical across all six adapters. This is core lifecycle semantics that happens to live in the adapter layer rather thanpackages/core. - Import list from
@zag-js/core— every adapter imports the exact same set:createScope,findTransition,getExitEnterStates,hasTag,INIT_STATE,MachineStatus,matchesState,resolveStateValue. None of these are ever reimplemented per framework — a new adapter must import, not rewrite, them. normalizeProps's outer shape — every adapter callscreateNormalizer<PropTypes>(fn)from@zag-js/types; onlyfn's body differs.
Genuinely different (a new adapter must decide)
| Axis | What differs | Why |
|---|---|---|
| Mount timing | react defers the actual state.invoke(initial, INIT_STATE) by one microtask inside a layout effect; preact runs it synchronously inside the layout effect with no microtask wrapper; solid/vue/svelte use their native post-DOM-attach hook directly; vanilla has no timing at all — it's whenever .start() is called. | React's microtask deferral exists to let StrictMode's synchronous double-invoke of layout effects settle before the "real" state transition fires, and to play well with flushSync semantics. No other framework needs this because none of them double-invoke effects synchronously the way React StrictMode does. |
| StrictMode / re-mount cleanup composition | Only react's machine.ts has the explicit callAll(existing, cleanup) merge-with-existing-cleanup comment calling out StrictMode by name. Preact/solid/vue/svelte/vanilla assume a cleanup always starts from an empty map per state-enter. | Only React has an intentional dev-mode double-invoke of effects to catch missing cleanups; every other framework in this set runs effects exactly once per mount. |
flush() implementation | react/preact: queueMicrotask(() => flushSync(() => fn())). vue: nextTick().then(() => fn()) (promise-based, one tick later). svelte: flushSync(() => queueMicrotask(() => fn())) — note the order is reversed relative to react (flush wraps the microtask scheduling, not the other way around). solid: (fn) => fn() — same-tick, no deferral at all. vanilla: flush: identity in getParams() (i.e. calls fn() immediately, same as solid) but queueMicrotask(fn) in the context factory's flush passed to machine.context() — vanilla actually uses two different flush implementations depending on call site. | Each framework's own synchronous-render guarantee differs: React needs flushSync to force a synchronous commit outside its normal batching; Solid/Vue/Svelte's own reactivity is fine-grained enough that ordinary signal/ref writes are effectively synchronous or resolve before the next microtask matters to the machine. |
| Bindable equality default | react/solid/vue/svelte/vanilla default to Object.is; preact defaults to isEqual from @zag-js/utils (a deeper/looser comparison). | No documented rationale in source; this is a genuine, easy-to-miss inconsistency — a new adapter should default to Object.is to match five of six existing adapters unless there's a specific reason to deviate. |
| Reactivity trigger for consumers | react/preact: framework useState setter. solid: signal write. vue: shallowRef write. svelte: $state rune mutation. vanilla: manual subscribe/publish array iteration, no framework involvement at all. | This is the single biggest axis of divergence and the one a new adapter must design from scratch — pick whatever primitive the target framework/runtime offers for "notify interested parties synchronously on mutation." If the target has no such primitive, vanilla's manual pub/sub is the fallback pattern (see the vanilla deep-dive). |
| Vue event buffering before mount | Only vue's machine.ts buffers send() calls into pendingEvents when called before onMounted fires, replaying them once started. No other adapter has this — react/preact/solid/svelte/vanilla simply drop/no-op a send() called before start (guarded by the status !== MachineStatus.Started check). | Documented in the code comment: "child mounts before parent in Vue" — a Vue-specific component instantiation ordering quirk that doesn't exist in the other frameworks' mount orderings. |
normalizeProps attribute renames and casing rules | See the full table on the responsibilities page — react/preact stay close to JSX camelCase; solid/vue/svelte/vanilla each independently lowercase, hyphenate styles, and maintain separately-declared SVG-attribute-case allowlists that are not textually identical to each other. | Each output target (React's synthetic DOM props, Solid's fine-grained DOM prop setter, Vue's template compiler conventions, Svelte's native DOM attribute names, vanilla's raw setAttribute calls) has different casing/serialization requirements at the point props actually touch the DOM or framework runtime. |
children handling in normalizeProps | solid → textContent key. vue → innerHTML key (with a dev warning for non-string children). svelte/react/preact → passthrough, no special case. vanilla → no special case in normalize-props.ts, but spread-props.ts sets node.innerHTML if it sees a literal children attribute key. | Each framework's rendering primitive expects text content through a different channel; solid's fine-grained DOM setter recognizes a magic textContent prop, Vue's innerHTML is the idiomatic escape hatch for raw string content. |
| Track/watch scheduling | react/preact: gated by a useEffect dependency array with a mount-skip guard. solid/svelte: manual isEqual diff loop inside a single reactive effect (createEffect/$effect). vue: native watch() with a manual isEqual loop over its callback args. vanilla: an explicit tracker array diffed inside callTrackers(), invoked from every publish() — i.e. on every mutation, not once per render. | There is no shared "render pass" concept across these runtimes for track to hook into uniformly; each adapter reimplements dependency diffing against whatever scheduling unit its host framework exposes (effect, watcher, or — in vanilla's case — the publish pipeline itself). |
| Portal / spreadProps / reflect presence | Only react and svelte ship a working portal primitive (portal.tsx / portal.ts); vue has none; preact's index.ts exports "./portal" but no such file exists in src/ in this tree (likely stale). Only vanilla ships spreadProps (imperative DOM reconciliation has no JSX/template-compiler equivalent need). Only svelte ships reflect() (a Proxy that keeps object property reads live against a changing source — needed because Svelte templates destructure/spread objects in ways that would otherwise snapshot stale values). | These are genuinely optional, framework-shaped utilities layered on top of the core five responsibilities — not part of the Service<T> contract itself. A new adapter only needs them if its target framework has the same structural need (a template compiler that would otherwise snapshot reactive reads, or no native portal concept). |
Summary heuristic
If a piece of logic references machine, Params<T>, Service<T>, or calls into @zag-js/core's exported functions — it is contract and should be copied near-verbatim, adjusted only for the target language's closure/class idiom. If it references a framework-specific reactive primitive (a hook, a rune, a ref, a signal) or a framework-specific rendering convention (JSX camelCase, Vue templates, Svelte's compiler) — it is framework idiom and must be redesigned around whatever the target framework/runtime actually offers.