The five responsibilities
Every adapter implements the same five jobs. This page compares HOW each of the six frameworks does it, file by file.
1. Bindable — controlled/uncontrolled value cells
Bindable<T> (packages/core/src/types.ts) is the primitive behind both machine state and every context key. Its contract:
export interface Bindable<T> {
initial: T | undefined
ref: any
get: () => T
set(value: ValueOrFn<T>): void
invoke(nextValue: T, prevValue: T): void
hash(value: T): string
}Each adapter implements a bindable/useBindable/createBindable factory taking BindableParams<T> (defaultValue, value, hash, isEqual, onChange, debug, sync). The core logic is identical everywhere: compute controlled = value !== undefined; if controlled, always read props().value; if not, own local reactive storage; call onChange(next, prev) only when !eq(next, prev).
| Framework | File | Storage primitive | Equality default | Prev-value tracking | set() sync path |
|---|---|---|---|---|---|
| react | src/bindable.ts | useState + a live useRef mirror | Object.is | useRef updated in a useSafeLayoutEffect([value, props().value]) | props().sync ? flushSync : identity |
| preact | src/bindable.ts | useState (preact/hooks) + useRef mirror | isEqual from @zag-js/utils (deep-ish, not Object.is) | useLayoutEffect([value, props().value]) (no SSR fallback, unlike react) | props().sync ? flushSync : identity (uses preact/compat flushSync) |
| solid | src/bindable.ts | createSignal | Object.is | createEffect writes both valueRef.current and prevValue.current from the same reactive read | no sync branch — Solid signals are already synchronous; set just calls setValue |
| vue | src/bindable.ts | shallowRef | Object.is | no separate prev-tracking effect — prev is read inline in set() from controlled.value ? props().value : v.value before assigning next | no sync branch — Vue refs mutate synchronously; no flushSync import at all |
| svelte | src/bindable.svelte.ts | $state rune | Object.is | $effect.pre recomputes valueRef/prevValue plain objects before DOM updates; set() wraps the mutation in untrack() to avoid self-triggering | props().sync ? flushSync : identity, using Svelte's own flushSync from the "svelte" package, additionally wrapped in untrack() |
| vanilla | src/bindable.ts | @zag-js/store proxy({ value }) | Object.is | no dedicated prev-ref — prev is computed inline in set() the same way as Vue, from the proxy's current value | no sync branch at all — the proxy write is synchronous by construction; consumers subscribe via subscribe(store, cb) from @zag-js/store |
Notably identical: the exported *.cleanup and *.ref static methods attached to every bindable function (BindableFn in core/src/types.ts requires them). *.ref is a plain non-reactive get/set box in five of six adapters (vanilla, solid, vue, svelte all use a closured let value = defaultValue; react/preact use useRef). *.cleanup(fn) wires framework unmount to an arbitrary cleanup callback — except vanilla, where it is an explicit no-op: bindable.cleanup = (_fn) => { } (packages/frameworks/vanilla/src/bindable.ts).
2. Reactivity bridge
What makes the framework actually re-render when the machine's state or context bindables change? Zag deliberately does not memoize the Service object itself in most adapters — getState(), getEvent(), and the returned state/event are rebuilt fresh on every call to useMachine. Memoization instead happens inside the reactive primitives that back props, scope, and computed.
| Framework | Re-render trigger | Where memoization lives |
|---|---|---|
| react | useState setter inside bindable triggers component re-render; state/context updates call it | scope via useMemo(..., [userProps]); stable function identities via useStableFn/useConst (src/stable.ts) so consumers can depend on send/computed without re-subscribing |
| preact | same as react (preact's useState) | scope via useMemo(..., [userProps]); no stable.ts equivalent — send/computed/guard are re-created every render (plain closures, not wrapped) |
| solid | signal writes (setValue in bindable) trigger fine-grained DOM updates in dependent computations, no component re-render concept | scope, props are createMemo; state/event/context exposed via solid's own mergeProps (src/merge-props.ts) which defines lazy getters per source instead of eagerly merging |
| vue | shallowRef writes trigger Vue's reactivity graph | scope and props are computed(); ctx, getState()/getEvent() are plain object literals with getters (get scope() { return scope.value }) recreated on each service access |
| svelte | $state rune mutation triggers Svelte 5's fine-grained reactivity | scope/props via $derived/$derived.by; the returned Service uses Svelte-native getters (get state(), get event()) so property reads stay reactive when destructured in a template via reflect() (src/reflect.ts) — a Proxy that re-reads the source object on every property access and rebinds methods |
| vanilla | manual pub/sub: this.notify() → this.publish() → iterates this.subscriptions; wired to bindable stores via @zag-js/store's subscribe(store, cb) | no framework memoization at all — consumers call service.state/service.context and get a freshly built plain object each time; the "memo" concept is replaced by the explicit dependency-diffing in callTrackers() (see Track below) |
3. Lifecycle — start/init timing, effect registration, StrictMode/re-mount handling
All six drivers share the same state machine: on mount, invoke the initial state transition with prevState = INIT_STATE (the sentinel exported as INIT_STATE = "__init__" from core/src/state.ts), which causes machine.entry and the initial state's entry/effects to run; on unmount, run all accumulated cleanup functions and machine.exit. What differs is exactly *when* relative to DOM commit, and how re-mounts are handled.
| Framework | Mount hook | Timing relative to DOM | Re-mount / StrictMode handling | Unmount hook |
|---|---|---|---|---|
| react | useSafeLayoutEffect (useLayoutEffect if document exists, else useEffect — src/use-layout-effect.ts) | Layout effect body wraps the actual init in queueMicrotask, so the state transition itself runs one microtask after DOM commit | hydratedStateRef persists the last-known state value across the effect's cleanup/re-run; the onChange handler explicitly composes cleanups with callAll so a StrictMode double-invoke doesn't clobber the first mount's cleanup — see the code comment in machine.ts: "React Strict Mode remount that re-enters the same path before the prior cleanup ran" | cleanup fn returned from the layout effect; runs effects then queueMicrotask(() => action(machine.exit)) |
| preact | useLayoutEffect (preact/hooks, unconditional — no SSR guard) | Init runs synchronously inside the layout effect body — no queueMicrotask wrapper unlike react | no special StrictMode composition logic (preact has no StrictMode double-invoke); tracks current state via a separate currentStateRef updated eagerly in send | cleanup fn returned from the same layout effect; calls action(machine.exit) synchronously (not deferred via microtask) |
| solid | onMount | Runs after the component's DOM is attached (Solid schedules onMount post-render) | no re-mount composition logic — Solid doesn't double-invoke effects | onCleanup; guarded by if (status !== MachineStatus.Started) return |
| vue | onMounted | After DOM insertion, standard Vue timing | none; additionally buffers send() calls into pendingEvents if called before mount completes ("child mounts before parent in Vue" per the code comment) and replays them once started | onBeforeUnmount |
| svelte | onMount (from "svelte") | After the component is attached to the DOM | none | onDestroy; guarded by if (status !== MachineStatus.Started) return |
| vanilla | explicit .start() method, called by the consumer | whenever the caller invokes it — no framework schedules this | n/a — caller owns the lifecycle entirely | explicit .stop() method |
Side-by-side, the microtask deferral is a real divergence worth flagging for a new adapter:
// react — packages/frameworks/react/src/machine.ts
useSafeLayoutEffect(() => {
queueMicrotask(() => {
const started = statusRef.current === MachineStatus.Started
statusRef.current = MachineStatus.Started
const initialState = hydratedStateRef.current ?? state.initial!
state.invoke(initialState, started ? state.get() : INIT_STATE)
})
...
}, [])
useLayoutEffect(() => {
const started = statusRef.current === MachineStatus.Started
statusRef.current = MachineStatus.Started
const initialState = hydratedStateRef.current ?? state.initial!
state.invoke(initialState, started ? state.get() : INIT_STATE)
...
}, [])4. normalizeProps — PropTypes mapping per framework
createNormalizer (packages/types/src/prop-types.ts) wraps a plain (props: Dict) => Dict transform function in a Proxy so that normalizeProps.button({...}), normalizeProps.input({...}), etc. all route through the same function regardless of tag name, with a special case for normalizeProps.style which unwraps the result. Adapters differ only in the transform function passed in.
| Framework | File | Attribute renames | Event convention | Style handling | children/textContent |
|---|---|---|---|---|---|
| react | src/normalize-props.ts | none — identity function (v) => v; React's JSX runtime does its own attribute mapping | native React onX camelCase, untouched | passthrough object, React handles serialization | passthrough |
| preact | src/normalize-props.ts | onFocus→onfocusin, onBlur→onfocusout, onDoubleClick→onDblClick, onChange→onInput via an explicit eventMap | lowercase DOM-ish event names for focus/blur (preact/compat quirk), rest pass through | passthrough object | passthrough |
| solid | src/normalize-props.ts | eventMap: onFocus→onFocusIn, onBlur→onFocusOut, onDoubleClick→onDblClick, onChange→onInput, defaultChecked→checked, defaultValue→value, htmlFor→for, className→class; also drops readOnly: false entirely | Solid's native onX camelCase (delegated events), remapped subset above | object → cssify() converts camelCase keys to hyphenated (except custom props starting --) via a memoizing hyphenateStyleName cache | children string → sets textContent key (Solid-specific escape hatch, not children prop) |
| vue | src/normalize-props.ts | propMap: htmlFor→for, className→class, onDoubleClick→onDblclick, onChange→onInput, onFocus→onFocusin, onBlur→onFocusout, defaultValue→value, defaultChecked→checked; all other non-on, non-preserved keys are lowercased via toVueProp; an explicit preserveKeys allowlist (viewBox, strokeWidth, etc.) skips lowercasing for SVG/camelCase-sensitive attrs | on-prefixed keys get PascalCase rest: onCustomThing → onCustomThing preserved via on${toCase(prop.substr(2))} | passthrough object (Vue's runtime handles style objects natively) | children string → innerHTML (not textContent); warns via console.warn in dev if children is non-primitive |
| svelte | src/normalize-props.ts | propMap: className→class, defaultChecked→checked, defaultValue→value, htmlFor→for, onBlur→onfocusout, onChange→oninput, onFocus→onfocusin, onDoubleClick→ondblclick (all lowercase, Svelte's native DOM event attribute style); unmapped keys pass through toSvelteProp which lowercases unless in preserveKeys | lowercase native DOM event attribute names (onclick, not onClick) | object → toStyleString() serializes to a CSS string (key:value; pairs, camelCase→kebab-case except -- custom props) — same algorithm duplicated in vanilla's normalize-props.ts | not special-cased in normalizeProps itself (Svelte templates interpolate children directly) |
| vanilla | src/normalize-props.ts | same propMap shape as svelte/vue (onFocus→onFocusin, onBlur→onFocusout, onChange→onInput, onDoubleClick→onDblclick, htmlFor→for, className→class, defaultValue→value, defaultChecked→checked) | mapped subset above, otherwise lowercased key (since output is real DOM attribute names) | object → toStyleString() (identical algorithm to svelte's, independently duplicated — not shared) | no special children case in normalize-props.ts itself; spread-props.ts handles a literal children attr by setting node.innerHTML = value |
All non-react/preact-JSX adapters (solid, vue, svelte, vanilla) additionally lowercase or special-case SVG attributes to avoid mangling camelCase SVG properties like viewBox/strokeWidth — each keeps its own preserveKeys/caseSensitiveSvgAttrs allowlist, and the four lists are not textually identical (compare solid/src/normalize-props.ts — no SVG allowlist at all, it emits lowercase attributes and relies on cssify only for style — versus vue/src/normalize-props.ts's preserveKeys array versus svelte/src/normalize-props.ts's identical-looking but separately-declared preserveKeys Set versus vanilla/src/spread-props.ts's caseSensitiveSvgAttrs Set, which has more entries — clipPath, clipRule, fillRule, stroke-* props — than vue's or svelte's).
5. refs / track / watch
machine.watch (Machine<T>["watch"] in core/src/types.ts) is called once per useMachine invocation/setup with the full Params<T> object, and typically calls track(deps, effect) one or more times internally (machine-authored code, not shown here) to run side effects when specific derived values change between renders.
| Framework | File | Dependency comparison | When it runs | First-run behavior |
|---|---|---|---|---|
| react | src/track.ts | useEffect dependency array — deps are unwrapped (typeof d === "function" ? d() : d) so React's own Object.is array-diffing decides when to re-run | after commit, standard useEffect timing | skipped: a called ref gates the very first invocation so effect() only fires on genuine changes, not initial mount |
| preact | src/track.ts | identical implementation to react's, byte-for-byte except the import source (preact/hooks vs react) | after commit | same double-useEffect mount-guard pattern as react |
| solid | src/track.ts | manual loop using isEqual from @zag-js/utils inside a single createEffect | Solid's reactive scheduler (synchronous-ish, fine-grained) | isFirstRun flag records deps but does not call effect() on the first pass |
| vue | src/track.ts | watch(() => deps.map(d => d()), (current, previous) => ...) with a manual isEqual loop over the callback args Vue already supplies | Vue's watch scheduler (post-flush by default) | Vue's watch does not invoke the callback on registration by default (immediate not set), so effectively equivalent to skipping first run |
| svelte | src/track.svelte.ts | manual loop with isEqual, structurally identical to solid's track.ts | inside $effect (Svelte 5 rune, runs after DOM updates) | isFirstRun flag, same pattern as solid |
| vanilla | src/machine.ts (inline in VanillaMachine.getParams() and callTrackers(), no separate track.ts file) | isEqual comparison of deps.map(dep => dep()) against a cached fn.prev array stashed as a property on the effect function itself | manually driven — callTrackers() runs from publish(), which fires whenever a bindable notifies (state or context change), not on a framework render cycle | the first call to track() seeds fn.prev immediately without invoking fn (see getParams(): track: (deps, fn) => { fn.prev = deps.map(dep => dep()); this.trackers.push(...) }) |
refs (BindableRefs<T>) is trivial and byte-for-byte identical across solid, vue, svelte, and vanilla — a plain { current: refs } box with get/set by key (compare packages/frameworks/solid/src/refs.ts, packages/frameworks/vue/src/refs.ts, packages/frameworks/svelte/src/refs.svelte.ts, packages/frameworks/vanilla/src/refs.ts — all four files are the same 12 lines). React and preact instead wrap the box in a framework useRef (packages/frameworks/react/src/refs.ts, packages/frameworks/preact/src/refs.ts) purely so the object identity survives re-renders without extra machinery — functionally equivalent, since none of these boxes are ever reactive; they exist purely to give the machine a mutable slot that survives across calls.