@macrulez/vue-state-machine
v0.2.6
Published
Reactive finite state machines and statecharts for Vue 3 — declarative transitions, parallel regions, guards, actions and persistence.
Maintainers
Readme
State Machine

Lightweight reactive finite state machines (FSM / statechart) for Vue 3 — declarative states and transitions, parallel regions, guards, actions, persist, and a composable API — with a single peer dependency.
Features
defineMachine()— pure config factory with dev-time validation; no Vue dependency — testable in NodeuseMachine()— composable that wraps a machine in Vue reactivity; reactivestate,context,send(),matches(),can()- Guards — synchronous predicates that block transitions; exception treated as
false - Actions — sync or async side-effects on entry, exit, or transition; return
Partial<context>to update state - Event queue —
send()adds to a queue and processes events sequentially; no race conditions with async actions - Parallel regions — multiple independent sub-machines active at the same time inside a state
useWizard()— built on top ofuseMachine;next(),prev(),goTo(), asynccanProceed,onEnter/onLeavehooks that can write to context, circular mode- Persist — optional snapshot serialization to
localStorage(or any customStorage) per machine instance - Transition history — configurable depth, useful for debugging and undo flows
useSharedMachine()— singleton machine shared between unrelated components without Pinia- DevTools — separate
/devtoolsentry point; custom panel in Vue DevTools showing every registered machine's state and context - Full TypeScript —
TState,TEvent,TContextgenerics inferred automatically from the config - XState v5 compatible subset — migrate by swapping
createMachine→defineMachineandassign()→ plain return value - SSR-safe — no
window/localStoragein the core; persist is silently skipped server-side - ≤ 4 KB gzip for the core (
defineMachine+useMachine)
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.
Installation
npm install @macrulez/vue-state-machinePeer dependency:
npm install vue@>=3.3Quick start
<script setup lang="ts">
import { defineMachine, useMachine } from '@macrulez/vue-state-machine'
const trafficLight = defineMachine({
id: 'traffic',
initial: 'red',
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: { on: { NEXT: { target: 'red' } } },
},
})
const { state, send } = useMachine(trafficLight)
</script>
<template>
<div :class="state">
<p>Current: {{ state }}</p>
<button @click="send('NEXT')">Next</button>
</div>
</template>state is a reactive Ref<'red' | 'green' | 'yellow'>. Clicking the button transitions the machine and Vue re-renders automatically.
More examples
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.
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.
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 succeedsA 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.
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.Documentation & links
- 📖 Full documentation: npm.vuecraft.ru/en/packages/vue-state-machine
- 🌐 VueCraft: vuecraft.ru/en
- 👤 Author: macrulez.ru/en
- 💻 GitHub: macrulezru/vue-state-machine
- 📦 NPM: @macrulez/vue-state-machine
- 🐛 Issues: github.com/macrulezru/vue-state-machine/issues
License
MIT
💖 Support the project
Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.
Thank you for being part of this journey. ❤️
