marko-ui

The vanilla adapter deep-dive

Vanilla has no reactive runtime, making it the closest analog to a resumability-constrained target (e.g. Marko). This page documents exactly how it schedules updates, subscribes consumers, and re-applies props to the DOM.

packages/frameworks/vanilla/src/machine.ts exports a class, VanillaMachine<T>, rather than a hook. There is no framework scheduler, no signals, no virtual DOM — every other adapter borrows a reactive primitive from its host framework (useState, a Solid signal, a Vue ref, a Svelte rune); vanilla instead builds the entire notify/subscribe/apply pipeline from three small pieces: @zag-js/store's proxy/subscribe, a manual subscriber list, and imperative DOM diffing in spread-props.ts.

1. The bindable store

packages/frameworks/vanilla/src/bindable.ts backs both state and every context key with a @zag-js/store proxy:

const store = proxy({ value: initial as T })

return {
  initial,
  ref: store,
  get() {
    return controlled() ? (props().value as T) : store.value
  },
  set(nextValue) {
    const prev = controlled() ? (props().value as T) : store.value
    const next = isFunction(nextValue) ? nextValue(prev as T) : nextValue
    if (!controlled()) store.value = next
    if (!eq(next, prev)) {
      props().onChange?.(next, prev)
    }
  },
  ...
}

Mutating store.value is what triggers downstream notification — the proxy is observable via subscribe() from the same package. There is no debouncing or batching at this layer: every set() call synchronously assigns store.value.

2. Wiring stores to the publish pipeline

In the VanillaMachine constructor (packages/frameworks/vanilla/src/machine.ts), every context bindable's underlying store and the state bindable's store are individually subscribed to a single notify callback:

// context stores
if (context) {
  Object.values(context).forEach((item: any) => {
    const unsub = subscribe(item.ref, () => this.notify())
    this.cleanups.push(unsub)
  })
}


this.cleanups.push(subscribe(this.state.ref, () => this.notify()))

notify() is a one-line trampoline to publish():

private notify = () => {
  this.publish()
}

private publish = () => {
  this.callTrackers()
  this.subscriptions.forEach((fn) => fn(this.service))
}

So the scheduling model is: every store mutation synchronously fans out to every subscriber, no batching, no microtask deferral, no dedup across multiple mutations in the same tick (contrast with react/preact's flushSync + queueMicrotask combo, or Vue's nextTick). If a single send() triggers a state change followed by several context mutations inside action handlers, each one calls publish() independently and consumers are notified once per mutation, not once per send.

3. Consumer subscription API

Consumers do not get reactive bindings automatically. They call machine.subscribe(fn) (a plain array-based pub/sub, not @zag-js/store's):

subscribe = (fn: (service: Service<T>) => void) => {
  this.subscriptions.push(fn)
  return () => {
    const index = this.subscriptions.indexOf(fn)
    if (index > -1) this.subscriptions.splice(index, 1)
  }
}

Each invocation of fn receives a freshly built this.service getter (see below) — there is no diffing of the previous service against the next; it is the consumer's job to decide what changed and what DOM to touch, typically by calling normalizeProps + spreadProps again on every notification.

4. The service getter

VanillaMachine.service is a getter, not a cached field — every access (including the one performed by publish() right before calling each subscriber) rebuilds the object fresh from current bindable/ref state:

get service(): Service<T> {
  return {
    state: this.getState(),
    send: this.send,
    context: this.context,
    prop: this.prop,
    scope: this.scope,
    refs: this.refs,
    computed: this.computed,
    event: this.getEvent(),
    getStatus: () => this.status,
  } as Service<T>
}

5. Lifecycle: explicit start/stop, no framework hook

Every other adapter ties start/stop to a framework mount hook. Vanilla exposes them as plain methods the caller must invoke:

start() {
  this.status = MachineStatus.Started
  this.state.invoke(this.state.initial!, INIT_STATE)
  this.setupTrackers()
}

stop() {
  this.effects.forEach((fn) => fn?.())
  this.effects.clear()
  this.transition = null
  this.action(this.machine.exit)
  this.cleanups.forEach((unsub) => unsub())
  this.cleanups = []
  this.subscriptions = []
  this.status = MachineStatus.Stopped
}

setupTrackers() just calls this.machine.watch?.(this.getParams()) — same as every other adapter — but because there's no reactive scheduler, the "watch" pattern here is degraded to whatever track() itself implements (see below), not tied to a framework re-render.

6. Track/watch without a scheduler

track(deps, fn) (inlined in getParams(), no separate file) seeds fn.prev immediately and pushes { deps, fn } onto this.trackers:

track: (deps: any[], fn: any) => {
  fn.prev = deps.map((dep) => dep())
  this.trackers.push({ deps, fn })
},

Trackers are evaluated inside callTrackers(), called from everypublish() — i.e. on every state or context mutation, not on a fixed schedule:

private callTrackers = () => {
  this.trackers.forEach(({ deps, fn }) => {
    const next = deps.map((dep) => dep())
    if (!isEqual(fn.prev, next)) {
      fn()
      fn.prev = next
    }
  })
}

This makes vanilla's tracking O(mutations × trackers) rather than O(renders × trackers) — it re-diffs every tracker's dependency array on every single notification, since there is no concept of a "render pass" to batch against.

7. Re-applying props to the DOM: spreadProps

This is vanilla's unique fifth file with no equivalent in any other adapter (packages/frameworks/vanilla/src/spread-props.ts). It is the imperative replacement for what JSX/Vue templates/Svelte compilers normally generate: given a DOM Element and a plain attrs object (already run through normalizeProps), it diffs against the previously-applied attrs for that exact element and mutates only what changed.

Key mechanics:

8. Prop reactivity without a framework: updateProps

Since there's no framework re-render to re-derive userProps automatically, the consumer must call machine.updateProps(newProps) manually whenever the input props should change. This merges the new props over a snapshot of the previous ones using a recursive plain-object merge (packages/frameworks/vanilla/src/merge-machine-props.ts, mergeMachineProps) that skips undefined values so partial updates don't blow away unrelated keys, then calls this.notify() to trigger a publish pass with the new prop function result.

updateProps(newProps) {
  const prevSource = this.userPropsRef.current
  this.userPropsRef.current = () => {
    const prev = runIfFn(prevSource)
    const next = runIfFn(newProps)
    return mergeMachineProps(prev, next)
  }
  this.notify()
}

Why this matters for a resumability-constrained target

A framework without an ambient reactive runtime — most relevantly one built around server-rendered, resumable output rather than a client VDOM/signal graph — will need to replicate roughly this same shape:

  1. A store primitive with explicit get/set/subscribe (vanilla borrows @zag-js/store's proxy; a from-scratch implementation only needs get/set/subscribe semantics, not full proxy trapping).
  2. A manual subscription list (subscriptions: Array<(service) => void>) that publish() iterates synchronously.
  3. Explicit start()/stop() lifecycle methods, called at whatever point the host environment considers "mounted"/"unmounted" — there is no framework hook to lean on.
  4. An imperative DOM-attribute reconciler equivalent to spreadProps, keyed per element so multiple machines can coexist on shared DOM.
  5. A manual updateProps-style entry point since there is no reactive prop pass-through from a parent component.