Skip to content

Advanced Patterns

Parallel states

A state can declare parallel regions — a set of independent sub-machines that all become active when the parent state is entered and are destroyed when it is left.

ts
const editor = defineMachine({
  id: 'editor',
  initial: 'editing',
  states: {
    editing: {
      parallel: {
        saving: {
          initial: 'idle',
          states: {
            idle: { on: { START_SAVE: { target: 'saving' } } },
            saving: { on: { SAVE_DONE: { target: 'saved' } } },
            saved: {},
          },
        },
        validation: {
          initial: 'valid',
          states: {
            valid: { on: { INVALIDATE: { target: 'invalid' } } },
            invalid: { on: { VALIDATE: { target: 'valid' } } },
          },
        },
      },
    },
    idle: {},
  },
})

send() delivers every event to all active regions. Each region handles it independently.

ts
const { matches, send } = useMachine(editor)

matches('editing') // main state
matches({ saving: 'idle' }) // region check
matches({ validation: 'valid' }) // another region

await send('INVALIDATE')
matches({ validation: 'invalid' }) // true
matches({ saving: 'idle' }) // still true — unaffected

Context conflict resolution: when two regions return a Partial<context> that touches the same field, the last region in declaration order wins. A console.warn is emitted in dev mode naming the conflicting regions and field.

Limitation: parallel regions support one level of nesting. Regions cannot themselves contain parallel. This is a deliberate choice to control complexity.

useWizard

A composable for multi-step forms built on top of defineMachine. The wizard machine is generated automatically from the steps array.

ts
function useWizard<TContext>(
  steps: WizardStep<TContext>[],
  options?: WizardOptions,
): WizardInstance<TContext>

WizardStep

FieldTypeDescription
idstringUnique step identifier (becomes a state name internally)
labelstring?Display label
componentComponent?Vue component to render for this step
canProceed(ctx) => boolean | Promise<boolean>Gate for next() and forward goTo(); may be async
onEnter(ctx) => voidCalled when the wizard enters this step
onLeave(ctx) => voidCalled when the wizard leaves this step

WizardOptions

OptionTypeDefaultDescription
initialStepnumber0Index of the starting step
allowSkipbooleanfalseSkip canProceed on forward goTo()
circularbooleanfalsenext() wraps from last step back to first

Return value

PropertyTypeDescription
currentStepRef<WizardStep>Currently active step object
currentIndexComputedRef<number>Zero-based index of the current step
totalStepsnumberTotal number of steps
progressComputedRef<number>0 to 1 based on current index
isFirstComputedRef<boolean>true on the first step
isLastComputedRef<boolean>true on the last step
historyRef<string[]>IDs of visited steps
next()Promise<boolean>Advance; calls canProceed first; returns false if blocked
prev()voidGo back (no guard)
goTo(id)Promise<boolean>Jump to step by id; respects canProceed unless allowSkip
reset()voidReturn to the initial step

Example

vue
<script setup lang="ts">
import { useWizard } from 'vue-state-machine'
import type { WizardStep } from 'vue-state-machine'
import StepInfo from './StepInfo.vue'
import StepAddress from './StepAddress.vue'
import StepPayment from './StepPayment.vue'

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

const steps: WizardStep<CheckoutCtx>[] = [
  {
    id: 'info',
    label: 'Your info',
    component: StepInfo,
    canProceed: (ctx) => !!ctx.name && !!ctx.email,
  },
  {
    id: 'address',
    label: 'Delivery',
    component: StepAddress,
    canProceed: (ctx) => !!ctx.address,
  },
  {
    id: 'payment',
    label: 'Payment',
    component: StepPayment,
    onEnter: () => trackEvent('payment_step_entered'),
  },
]

const { currentStep, progress, isFirst, isLast, next, prev } = useWizard(steps)
</script>

<template>
  <div>
    <progress :value="progress" max="1" />

    <component :is="currentStep.component" />

    <nav>
      <button :disabled="isFirst" @click="prev">Back</button>
      <button v-if="!isLast" @click="next">Next</button>
      <button v-else @click="submit">Place order</button>
    </nav>
  </div>
</template>

canProceed rules

  • May return a boolean or a Promise<boolean>
  • If it returns false, next() / forward goTo() return false and the wizard stays on the current step
  • If it throws, the same outcome — false is returned, the error is logged to console.error in dev mode
  • prev() and backward goTo() never check canProceed
  • allowSkip: true disables canProceed for goTo() only; next() always checks it