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

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

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.

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.

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

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.

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.

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.
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.
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' },
},
})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.
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 succeedsuseWizard builds the machine from a steps array on its own — canProceed blocks next() until required fields are filled in, and progress comes ready-made.
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.