Skip to main content
This guide walks through every building block available in createMachine, from the minimal toggle machine up to a fully featured counter with typed schema, computed values, refs, and effects.

Machine fundamentals

A state machine models stateful behavior using:
  • A finite set of states
  • Transitions between those states, triggered by events
  • Actions that execute during transitions
  • Context — reactive data that changes over time

Minimal toggle machine

import { createMachine } from "@zag-js/core"

interface ToggleSchema {
  state: "active" | "inactive"
  event: { type: "CLICK" }
}

export const machine = createMachine<ToggleSchema>({
  initialState() {
    return "active"
  },
  states: {
    active: {
      on: { CLICK: { target: "inactive" } },
    },
    inactive: {
      on: { CLICK: { target: "active" } },
    },
  },
})

Connecting to the DOM

The connect function bridges machine state to element props. It reads from service.state and dispatches events via service.send:
import type { Service } from "@zag-js/core"
import type { NormalizeProps, PropTypes } from "@zag-js/types"

export function connect<T extends PropTypes>(
  service: Service<ToggleSchema>,
  normalize: NormalizeProps<T>,
) {
  const { state, send } = service
  const active = state.matches("active")

  return {
    active,
    getButtonProps() {
      return normalize.button({
        type: "button",
        role: "switch",
        "aria-checked": active,
        onClick() {
          send({ type: "CLICK" })
        },
      })
    },
  }
}

Using it in a framework

import { useMachine, normalizeProps } from "@zag-js/react"
import { machine, connect } from "./toggle"

function Toggle() {
  const service = useMachine(machine)
  const api = connect(service, normalizeProps)

  return <button {...api.getButtonProps()}>{api.active ? "ON" : "OFF"}</button>
}

Nested states

A state can contain child states by nesting a states map and declaring an initial child state. Use dot notation to target nested states in transitions.
const machine = createMachine({
  initialState() {
    return "closed"
  },
  states: {
    closed: {
      on: {
        OPEN: { target: "open.idle" },
      },
    },
    open: {
      initial: "idle",
      states: {
        idle: {
          on: { SUBMIT: { target: "open.submitting" } },
        },
        submitting: {
          on: {
            SUCCESS: { target: "closed" },
            ERROR: { target: "open.idle" },
          },
        },
      },
      on: {
        CLOSE: { target: "closed" },
      },
    },
  },
})
Check active nested state:
service.state.matches("open.idle")      // true / false
service.state.matches("open.submitting") // true / false

Context

Context holds the reactive state of your machine — values that change over time and drive re-renders. Declare context using the bindable pattern, which provides built-in controlled/uncontrolled state management.
context({ bindable, prop }) {
  return {
    // Uncontrolled: machine owns the value
    count: bindable<number>(() => ({
      defaultValue: 0,
    })),

    // Controlled or uncontrolled: consumer decides
    name: bindable<string>(() => ({
      defaultValue: prop("defaultName") ?? "",
      value: prop("name"),           // controlled when provided
      onChange(value) {
        prop("onNameChange")?.({ name: value })
      },
    })),
  }
}

Bindable options

OptionDescription
defaultValueInitial value for uncontrolled usage
valueControlled value (when defined, the machine reflects this value)
onChangeCallback invoked when the value changes
isEqualCustom equality function (defaults to Object.is)
hashCustom hash function for change detection
syncWhether to use synchronous updates (framework-specific)

Reading and writing context

actions: {
  increment({ context }) {
    const current = context.get("count")
    context.set("count", current + 1)
  },

  reset({ context }) {
    const initial = context.initial("count")
    context.set("count", initial)
  },
}

Props

Props are the configuration your machine accepts from users. The props function normalizes them and sets defaults.
props({ props }) {
  return {
    // Defaults first
    step: 1,
    min: 0,
    max: 100,
    defaultValue: 0,

    // Spread user props last so they override defaults
    ...props,
  }
}
Access props anywhere via the prop function:
guards: {
  canIncrement({ prop, context }) {
    return context.get("count") < prop("max")
  },
},
actions: {
  notifyChange({ prop, context }) {
    prop("onChange")?.({ count: context.get("count") })
  },
},
Always spread ...props at the end of the returned object so user-provided values take precedence over your defaults.

Computed

Computed values are derived from context, props, refs, or other computed values. They are recalculated on demand and memoized by the framework.
computed: {
  isEven({ context }) {
    return context.get("count") % 2 === 0
  },

  fullName({ context }) {
    const first = context.get("firstName") ?? ""
    const last = context.get("lastName") ?? ""
    return `${first} ${last}`.trim()
  },

  // Computed values can depend on other computed values
  status({ computed, context }) {
    const isEven = computed("isEven")
    const count = context.get("count")
    return isEven ? `Even: ${count}` : `Odd: ${count}`
  },
},
Access computed values in guards, actions, and other computed:
guards: {
  isCountEven({ computed }) {
    return computed("isEven")
  },
},
actions: {
  logStatus({ computed }) {
    console.log(computed("status"))
  },
},

Refs

Refs store non-reactive references — class instances, DOM nodes, timers, caches — that do not need to trigger re-renders.
refs() {
  return {
    previousCount: null as number | null,
    operationCount: 0,
    history: [] as number[],
  }
},
actions: {
  increment({ refs, context }) {
    refs.set("previousCount", context.get("count"))
    const ops = refs.get("operationCount")
    refs.set("operationCount", ops + 1)
    context.set("count", (prev) => prev + 1)
  },
},
When to use refs vs context:
Use refs whenUse context when
The value does not affect renderingThe value drives UI updates
You need a class instance or timer IDYou need controlled/uncontrolled support
Storing cached or intermediate valuesConsumers need to read or set the value

Watch

The watch function lets you reactively respond to prop or context changes. Use track to declare dependencies; the callback runs whenever any dependency changes.
watch({ track, action, context, prop }) {
  // Re-run when count changes
  track([() => context.get("count")], () => {
    action(["logCount"])
  })

  // Re-run when step prop changes
  track([() => prop("step")], () => {
    action(["logStepChanged"])
  })

  // Track multiple dependencies
  track([
    () => context.get("firstName"),
    () => context.get("lastName"),
  ], () => {
    action(["updateFullName"])
  })
},
track is implemented using each framework’s native reactivity (useEffect in React, createEffect in Solid, watch in Vue, reactive statements in Svelte), so it integrates naturally with your framework’s change detection.

Scope

Scope provides framework-agnostic access to DOM utilities. It is available in props, actions, guards, computed, and effects.
interface Scope {
  id?: string
  ids?: Record<string, any>
  getRootNode: () => ShadowRoot | Document | Node
  getById: <T extends Element = HTMLElement>(id: string) => T | null
  getActiveElement: () => HTMLElement | null
  isActiveElement: (elem: HTMLElement | null) => boolean
  getDoc: () => typeof document
  getWin: () => typeof window
}
Common patterns:
effects: {
  focusInput({ scope }) {
    scope.getById("input")?.focus()
    return undefined
  },

  trackClickOutside({ scope, send }) {
    const doc = scope.getDoc()

    function handleClick(event: MouseEvent) {
      const root = scope.getRootNode() as Element
      if (!root.contains(event.target as Node)) {
        send({ type: "CLICK_OUTSIDE" })
      }
    }

    doc.addEventListener("click", handleClick)
    return () => doc.removeEventListener("click", handleClick)
  },

  preventScroll({ scope }) {
    const body = scope.getDoc().body
    const original = body.style.overflow
    body.style.overflow = "hidden"
    return () => { body.style.overflow = original }
  },
},

Actions, guards, and effects

Actions

Actions update context or fire callbacks. They run during state transitions (via entry, exit, or on handlers) or in response to watch triggers.
actions: {
  increment({ context, prop }) {
    const step = prop("step")
    context.set("count", (prev) => prev + step)
  },

  reset({ context }) {
    context.set("count", context.initial("count"))
  },

  notifyChange({ prop, context }) {
    prop("onChange")?.({ count: context.get("count") })
  },
},

Guards

Guards are boolean functions that determine whether a transition should proceed.
guards: {
  canIncrement({ prop, context }) {
    return context.get("count") < prop("max")
  },

  canDecrement({ prop, context }) {
    return context.get("count") > prop("min")
  },
},
Use guards in transitions:
on: {
  INCREMENT: {
    guard: "canIncrement",
    actions: ["increment"],
  },
},
You can also compose guards using createGuards:
import { createGuards } from "@zag-js/core"

const { and, or, not } = createGuards<CounterSchema>()

// ...
guard: and("canIncrement", "isEnabled"),
guard: or("isAdmin", "isOwner"),
guard: not("isDisabled"),

Effects

Effects run while a machine is in a given state and must return a cleanup function (or undefined). They are called when entering the state and cleaned up when exiting it or when the machine unmounts.
effects: {
  startPolling({ send }) {
    const id = setInterval(() => {
      send({ type: "POLL" })
    }, 5000)

    return () => clearInterval(id)
  },

  logOnEnter({ context }) {
    console.log("Entered state, count:", context.get("count"))
    return undefined // no cleanup needed
  },
},
Attach effects to a state:
states: {
  polling: {
    effects: ["startPolling"],
    on: {
      STOP: { target: "idle" },
    },
  },
},

Full example: counter machine

1

Define the TypeScript schema

import type { EventObject } from "@zag-js/core"

export interface CounterProps {
  step?: number
  min?: number
  max?: number
  defaultValue?: number
  value?: number
  onChange?: (details: { count: number }) => void
}

export interface CounterSchema {
  state: "idle"
  props: CounterProps
  context: { count: number }
  refs: { previousCount: number | null; operationCount: number }
  computed: { isEven: boolean; canIncrement: boolean; canDecrement: boolean }
  event: EventObject
  action: string
  guard: string
  effect: string
}
2

Create the machine

import { createMachine } from "@zag-js/core"

export const machine = createMachine<CounterSchema>({
  props({ props }) {
    return { step: 1, min: 0, max: 100, defaultValue: 0, ...props }
  },

  context({ prop, bindable }) {
    return {
      count: bindable(() => ({
        defaultValue: prop("defaultValue"),
        value: prop("value"),
        onChange(value) {
          prop("onChange")?.({ count: value })
        },
      })),
    }
  },

  refs() {
    return { previousCount: null, operationCount: 0 }
  },

  computed: {
    isEven: ({ context }) => context.get("count") % 2 === 0,
    canIncrement: ({ prop, context }) => context.get("count") < prop("max"),
    canDecrement: ({ prop, context }) => context.get("count") > prop("min"),
  },

  watch({ track, action, context, prop }) {
    track([() => context.get("count")], () => {
      action(["logCount"])
    })
    track([() => prop("step")], () => {
      action(["logStepChanged"])
    })
  },

  initialState() {
    return "idle"
  },

  states: {
    idle: {
      on: {
        INCREMENT: { guard: "canIncrement", actions: ["increment"] },
        DECREMENT: { guard: "canDecrement", actions: ["decrement"] },
        RESET:     { actions: ["reset"] },
      },
    },
  },

  implementations: {
    guards: {
      canIncrement: ({ computed }) => computed("canIncrement"),
      canDecrement: ({ computed }) => computed("canDecrement"),
    },
    actions: {
      increment({ context, prop, refs }) {
        refs.set("previousCount", context.get("count"))
        refs.set("operationCount", refs.get("operationCount") + 1)
        context.set("count", (prev) => prev + prop("step"))
      },
      decrement({ context, prop }) {
        context.set("count", (prev) => prev - prop("step"))
      },
      reset({ context }) {
        context.set("count", context.initial("count"))
      },
      logCount({ context, computed }) {
        const count = context.get("count")
        console.log(`Count: ${count} (${computed("isEven") ? "even" : "odd"})`)
      },
      logStepChanged({ prop }) {
        console.log("Step changed to:", prop("step"))
      },
    },
  },
})
3

Write the connect function

import type { Service } from "@zag-js/core"
import type { NormalizeProps, PropTypes } from "@zag-js/types"

export function connect<T extends PropTypes>(
  service: Service<CounterSchema>,
  normalize: NormalizeProps<T>,
) {
  const { send, computed } = service

  return {
    count: service.context.get("count"),
    isEven: computed("isEven"),
    canIncrement: computed("canIncrement"),
    canDecrement: computed("canDecrement"),

    getIncrementProps() {
      return normalize.button({
        type: "button",
        disabled: !computed("canIncrement"),
        onClick() { send({ type: "INCREMENT" }) },
      })
    },
    getDecrementProps() {
      return normalize.button({
        type: "button",
        disabled: !computed("canDecrement"),
        onClick() { send({ type: "DECREMENT" }) },
      })
    },
    getResetProps() {
      return normalize.button({
        type: "button",
        onClick() { send({ type: "RESET" }) },
      })
    },
  }
}
4

Consume in React

import { useMachine, normalizeProps } from "@zag-js/react"
import { machine, connect } from "./counter"

function Counter() {
  const service = useMachine(machine, { min: 0, max: 10 })
  const api = connect(service, normalizeProps)

  return (
    <div>
      <button {...api.getDecrementProps()}></button>
      <span>{api.count}</span>
      <button {...api.getIncrementProps()}>+</button>
      <button {...api.getResetProps()}>Reset</button>
    </div>
  )
}

TypeScript schema reference

FieldTypeDescription
statestring unionAll valid state names (e.g. "idle" | "active")
propsinterfaceUser-facing configuration accepted by the machine
contextRecord<string, any>Reactive values stored in the machine
refsRecord<string, any>Non-reactive references
computedRecord<string, any>Derived values
event{ type: string } & ...Event object shape (usually EventObject)
actionstringAction name union (usually string)
guardstringGuard name union (usually string)
effectstringEffect name union (usually string)
tagstringOptional state tag union for semantic grouping

Build docs developers (and LLMs) love