marko-ui

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).

FrameworkFileStorage primitiveEquality defaultPrev-value trackingset() sync path
reactsrc/bindable.tsuseState + a live useRef mirrorObject.isuseRef updated in a useSafeLayoutEffect([value, props().value])props().sync ? flushSync : identity
preactsrc/bindable.tsuseState (preact/hooks) + useRef mirrorisEqual 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)
solidsrc/bindable.tscreateSignalObject.iscreateEffect writes both valueRef.current and prevValue.current from the same reactive readno sync branch — Solid signals are already synchronous; set just calls setValue
vuesrc/bindable.tsshallowRefObject.isno separate prev-tracking effect — prev is read inline in set() from controlled.value ? props().value : v.value before assigning nextno sync branch — Vue refs mutate synchronously; no flushSync import at all
sveltesrc/bindable.svelte.ts$state runeObject.is$effect.pre recomputes valueRef/prevValue plain objects before DOM updates; set() wraps the mutation in untrack() to avoid self-triggeringprops().sync ? flushSync : identity, using Svelte's own flushSync from the "svelte" package, additionally wrapped in untrack()
vanillasrc/bindable.ts@zag-js/store proxy({ value })Object.isno dedicated prev-ref — prev is computed inline in set() the same way as Vue, from the proxy's current valueno 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.

FrameworkRe-render triggerWhere memoization lives
reactuseState setter inside bindable triggers component re-render; state/context updates call itscope via useMemo(..., [userProps]); stable function identities via useStableFn/useConst (src/stable.ts) so consumers can depend on send/computed without re-subscribing
preactsame 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)
solidsignal writes (setValue in bindable) trigger fine-grained DOM updates in dependent computations, no component re-render conceptscope, 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
vueshallowRef writes trigger Vue's reactivity graphscope 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 reactivityscope/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
vanillamanual 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.

FrameworkMount hookTiming relative to DOMRe-mount / StrictMode handlingUnmount hook
reactuseSafeLayoutEffect (useLayoutEffect if document exists, else useEffectsrc/use-layout-effect.ts)Layout effect body wraps the actual init in queueMicrotask, so the state transition itself runs one microtask after DOM commithydratedStateRef 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))
preactuseLayoutEffect (preact/hooks, unconditional — no SSR guard)Init runs synchronously inside the layout effect body — no queueMicrotask wrapper unlike reactno special StrictMode composition logic (preact has no StrictMode double-invoke); tracks current state via a separate currentStateRef updated eagerly in sendcleanup fn returned from the same layout effect; calls action(machine.exit) synchronously (not deferred via microtask)
solidonMountRuns after the component's DOM is attached (Solid schedules onMount post-render)no re-mount composition logic — Solid doesn't double-invoke effectsonCleanup; guarded by if (status !== MachineStatus.Started) return
vueonMountedAfter DOM insertion, standard Vue timingnone; 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 startedonBeforeUnmount
svelteonMount (from "svelte")After the component is attached to the DOMnoneonDestroy; guarded by if (status !== MachineStatus.Started) return
vanillaexplicit .start() method, called by the consumerwhenever the caller invokes it — no framework schedules thisn/a — caller owns the lifecycle entirelyexplicit .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.

FrameworkFileAttribute renamesEvent conventionStyle handlingchildren/textContent
reactsrc/normalize-props.tsnone — identity function (v) => v; React's JSX runtime does its own attribute mappingnative React onX camelCase, untouchedpassthrough object, React handles serializationpassthrough
preactsrc/normalize-props.tsonFocus→onfocusin, onBlur→onfocusout, onDoubleClick→onDblClick, onChange→onInput via an explicit eventMaplowercase DOM-ish event names for focus/blur (preact/compat quirk), rest pass throughpassthrough objectpassthrough
solidsrc/normalize-props.tseventMap: onFocus→onFocusIn, onBlur→onFocusOut, onDoubleClick→onDblClick, onChange→onInput, defaultChecked→checked, defaultValue→value, htmlFor→for, className→class; also drops readOnly: false entirelySolid's native onX camelCase (delegated events), remapped subset aboveobject → cssify() converts camelCase keys to hyphenated (except custom props starting --) via a memoizing hyphenateStyleName cachechildren string → sets textContent key (Solid-specific escape hatch, not children prop)
vuesrc/normalize-props.tspropMap: 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 attrson-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
sveltesrc/normalize-props.tspropMap: 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 preserveKeyslowercase 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.tsnot special-cased in normalizeProps itself (Svelte templates interpolate children directly)
vanillasrc/normalize-props.tssame 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.

FrameworkFileDependency comparisonWhen it runsFirst-run behavior
reactsrc/track.tsuseEffect dependency array — deps are unwrapped (typeof d === "function" ? d() : d) so React's own Object.is array-diffing decides when to re-runafter commit, standard useEffect timingskipped: a called ref gates the very first invocation so effect() only fires on genuine changes, not initial mount
preactsrc/track.tsidentical implementation to react's, byte-for-byte except the import source (preact/hooks vs react)after commitsame double-useEffect mount-guard pattern as react
solidsrc/track.tsmanual loop using isEqual from @zag-js/utils inside a single createEffectSolid's reactive scheduler (synchronous-ish, fine-grained)isFirstRun flag records deps but does not call effect() on the first pass
vuesrc/track.tswatch(() => deps.map(d => d()), (current, previous) => ...) with a manual isEqual loop over the callback args Vue already suppliesVue'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
sveltesrc/track.svelte.tsmanual loop with isEqual, structurally identical to solid's track.tsinside $effect (Svelte 5 rune, runs after DOM updates)isFirstRun flag, same pattern as solid
vanillasrc/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 itselfmanually driven — callTrackers() runs from publish(), which fires whenever a bindable notifies (state or context change), not on a framework render cyclethe 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.