Skip to content

State Machine

v0.2.7State & DataVue

Lightweight reactive finite state machines (FSM / statechart) for Vue 3 — declarative states and transitions, parallel regions, guards, and actions.

State Machine
Get started →
npm install @macrulez/vue-state-machine@latest
01 — Purpose

When you'd reach for this

A "Save" button isn't just "clicked" or "not clicked" — it's a whole chain of states (loading, confirming, error, available again), and vue-state-machine describes that chain as a single declaration where transitions are explicit and can't happen outside the rules.

A button shouldn't submit twice

While a save request is still running, clicking again shouldn't fire it a second time. Making "submitting" an explicit state makes a second click simply impossible, instead of relying on a separate check in every handler.

A multi-step checkout with conditional branches

A checkout step might require payment for some users and skip it for others, and going back isn't always allowed from every step — the whole flow is described in one place instead of conditions and flags scattered across components.

Two independent processes run at the same time

Loading the data and checking permissions run in parallel and shouldn't interfere with each other, but the final screen depends on how both turn out. Independent processes are described separately, instead of collapsing into one tangled set of flags.

Several boolean state flags contradict each other

"Loading," "error," "done" — three separate flags, even though only one of them can really be true at a time. States like these are treated as mutually exclusive from the start, instead of relying on nobody forgetting to reset a stale flag somewhere in the code.

02 — Features

At a glance

Declarative state machines and a reactive API

Declarative state machines and a reactive API

defineMachine creates a pure config with dev‑time validation — no Vue dependency, testable in Node. useMachine wraps it in reactive refs: state, context, send, matches, can, isDone. Everything updates automatically on transition, and the event queue guarantees sequential processing without race conditions.

Guards, actions, and event handling

Guards, actions, and event handling

Synchronous guards (predicates) block transitions and are used in can() for reactive UI control. Actions can be sync or async, execute in order exit → transition → entry, and return Partial<context> to update state. Exceptions in guards are treated as false.

Parallel states and useWizard

Parallel states and useWizard

A state can launch parallel regions — independent sub‑machines active simultaneously, each with its own state and context. useWizard builds a machine for multi‑step forms from an array of steps with canProceed (sync or async), onEnter/onLeave callbacks, and flexible navigation (next, prev, goTo).

Persistence, sharing, and DevTools

Persistence, sharing, and DevTools

Save state and context to localStorage (or custom Storage) with automatic restoration. useSharedMachine creates a singleton by id, accessible from any component without Pinia. A Vue DevTools panel shows all machines, their state, context, and transition history by polling the component tree in real time.

Lightweight, SSR, and XState v5 compatibility

Lightweight, SSR, and XState v5 compatibility

The core (~4 KB gzip) has no browser APIs — safe for SSR. Persist and DevTools activate only on the client. The API is compatible with XState v5: defineMachine instead of createMachine, actions return Partial<context> instead of assign, and invoke is replaced by async actions. Full typing with TState/TEvent/TContext inference.

Transition history and snapshot restore

Transition history and snapshot restore

Every machine keeps a transition history with a configurable depth — transitions, context, and timestamps, available for debugging. Take a snapshot of state and context at any point and restore a machine from it later, without re‑running the guards and actions of intermediate transitions.

03 — Quick example

See how it works

A machine with context, guards, and actions

A guard blocks the transition once there are already 3 attempts, an action increments the counter and clears the error — the form's logic lives declaratively in one place, not scattered across handlers.

machine.ts
import { defineMachine } from 'vue-state-machine'
import type { Action, Guard } from 'vue-state-machine'

type Ctx = { attempts: number; error: string | null }
type Ev = 'SUBMIT' | 'SUCCESS' | 'FAILURE' | 'RETRY'

const resetError: Action<Ctx, Ev> = () => ({ error: null })
const incrementAttempts: Action<Ctx, Ev> = (ctx) => ({ attempts: ctx.attempts + 1 })
const canRetry: Guard<Ctx, Ev> = (ctx) => ctx.attempts < 3

export const loginMachine = defineMachine<'idle' | 'loading' | 'error' | 'success', Ev, Ctx>({
  id: 'login',
  initial: 'idle',
  context: { attempts: 0, error: null },
  states: {
    idle: { on: { SUBMIT: { target: 'loading', actions: [resetError] } } },
    loading: {
      on: {
        SUCCESS: { target: 'success' },
        FAILURE: { target: 'error', actions: [incrementAttempts] },
      },
    },
    error: { on: { RETRY: { target: 'idle', guard: canRetry } } },
    success: { type: 'final' },
  },
})

Wiring it into a component

send() returns a promise that resolves once the transition finishes, can() synchronously checks whether an event would fire, isDone flips on the final state — all reactive, no manual computed properties.

usage.ts
import { useMachine } from 'vue-state-machine'
import { loginMachine } from './machine'

const { state, context, send, can, isDone } = useMachine(loginMachine)

async function submit() {
  await send('SUBMIT')
  try {
    await api.login()
    send('SUCCESS')
  } catch (e) {
    send({ type: 'FAILURE', message: String(e) })
  }
}

// state.value === 'error'  ->  `Failed. Attempts: ${context.value.attempts}/3`
// can('RETRY')             ->  whether the Retry button should be enabled
// isDone.value             ->  true once login succeeds

A multi-step wizard, no machine of your own

useWizard builds the machine from a steps array on its own — canProceed blocks next() until required fields are filled in, and progress comes ready-made.

checkout-wizard.ts
import { useWizard } from 'vue-state-machine'
import type { WizardStep } from 'vue-state-machine'

interface CheckoutCtx {
  name: string
  email: string
  address: string
}

const steps: WizardStep<CheckoutCtx>[] = [
  { id: 'info', label: 'Your info', canProceed: (ctx) => !!ctx.name && !!ctx.email },
  { id: 'address', label: 'Delivery', canProceed: (ctx) => !!ctx.address },
]

const { currentStep, progress, next, prev, isLast } = useWizard(steps)

// next() calls canProceed first and returns false if it's blocked — no
// manual validation gate before advancing to the next step.