@macrulez/vue-state-machine

Reactive finite state machines and statecharts for Vue 3 — declarative transitions, parallel regions, guards, actions and persistence.
2,677
Install
npm install @macrulez/vue-state-machine
Latest Version:0.2.7
Vue:3
License:MIT
Last Updated:Sep 25, 2026
Links: GitHub  ·  npm
Maintainer: macrulez

State Machine

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 Node
  • useMachine() — composable that wraps a machine in Vue reactivity; reactive state, 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 of useMachine; next(), prev(), goTo(), async canProceed, onEnter/onLeave hooks that can write to context, circular mode
  • Persist — optional snapshot serialization to localStorage (or any custom Storage) per machine instance
  • Transition history — configurable depth, useful for debugging and undo flows
  • useSharedMachine() — singleton machine shared between unrelated components without Pinia
  • DevTools — separate /devtools entry point; custom panel in Vue DevTools showing every registered machine's state and context
  • Full TypeScript — TState, TEvent, TContext generics inferred automatically from the config
  • XState v5 compatible subset — migrate by swapping createMachine → defineMachine and assign() → plain return value
  • SSR-safe — no window / localStorage in 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-machine

Peer dependency:

npm install vue@>=3.3

Quick 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 '@macrulez/vue-state-machine'
import type { Action, Guard } from '@macrulez/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 '@macrulez/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.

import { useWizard } from '@macrulez/vue-state-machine'
import type { WizardStep } from '@macrulez/vue-state-machine'

type 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


License

MIT


💖 Support the project

Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.

Donate via CryptoCloud

Thank you for being part of this journey. ❤️

Related Packages

use-trpc

Vue composable for tRPC v10

722 12
@xmachines/play-vue

Vue renderer for XMachines Play architecture

10,501
@rs-x/vue

Vue extension for the RS-X framework with composables to bind RS-X expressions t...

9,503 3
@xstate/vue

XState tools for Vue

2,731,424 30,363
xstate-vue2

XState tools for Vue

47,657 19

Version History

Version Released Vue License
0.2.7 ^3.3.0 MIT
0.2.6 ^3.3.0 MIT
0.2.5 ^3.3.0 MIT
0.2.4 ^3.3.0 MIT
0.2.3 ^3.3.0 MIT