Лёгкие реактивные конечные автоматы (FSM / statechart) для Vue 3 — декларативные состояния и переходы, параллельные регионы, guards и actions.

Кнопка «Сохранить» — это не просто «нажата» или «не нажата», а целая цепочка состояний (загрузка, подтверждение, ошибка, снова доступна) — vue-state-machine описывает эту цепочку одним объявлением, где переходы между состояниями заданы явно и не могут произойти в обход правил.
Пока запрос на сохранение выполняется, повторный клик не должен снова его запускать. Явное состояние «отправляется» делает повторный клик просто невозможным, вместо того чтобы полагаться на отдельную проверку в каждом обработчике.
Один из шагов может требовать оплаты для одних пользователей и пропускаться для других, а вернуться назад можно не с любого шага — весь этот маршрут описывается в одном месте вместо условий и флагов, разбросанных по компонентам.
Загрузка данных и проверка прав доступа идут параллельно и не должны мешать друг другу, но итоговый экран зависит от исхода обоих. Независимые процессы описываются раздельно, а не сваливаются в один запутанный набор флагов.
«Идёт загрузка», «ошибка», «готово» — три отдельных флага, хотя по логике приложения одновременно верным может быть только один. Такие состояния описываются как взаимоисключающие с самого начала, вместо того чтобы полагаться на то, что нигде в коде не забудут сбросить лишний флаг.

defineMachine создаёт чистую конфигурацию с валидацией на этапе разработки — без Vue-зависимостей, тестируемую в Node. useMachine оборачивает её в реактивные Ref: state, context, send, matches, can, isDone. Всё обновляется автоматически при переходе, а очередь событий гарантирует последовательную обработку без гонок.

Синхронные guards (предикаты) блокируют переходы и используются в can() для реактивного управления UI. Действия могут быть синхронными или асинхронными, выполняются в порядке exit → transition → entry и возвращают Partial<context> для обновления состояния. Исключения в guards трактуются как false.

Внутри одного состояния можно запустить параллельные регионы — независимые под-машины, активные одновременно, каждая со своим состоянием и контекстом. useWizard строит машину для многошаговых форм из массива шагов с canProceed (синхронным или асинхронным), колбэками onEnter/onLeave и гибкой навигацией (next, prev, goTo).

Сохраняйте состояние и контекст в localStorage (или кастомное Storage) с автоматическим восстановлением. useSharedMachine создаёт синглтон по id, доступный из любых компонентов без Pinia. Vue DevTools-панель показывает все машины, их состояние, контекст и историю переходов, опрашивая дерево компонентов в реальном времени.

Ядро (~4 KB gzip) не содержит браузерных API — безопасно для SSR. persist и DevTools активируются только на клиенте. API совместим с XState v5: defineMachine вместо createMachine, действия возвращают Partial<context> вместо assign, invoke заменяется асинхронными действиями. Полная типизация с выводом TState/TEvent/TContext.

Каждая машина хранит историю переходов с настраиваемой глубиной — переходы, контекст и метки времени доступны для отладки и построения журналов действий пользователя. Снимайте снапшот состояния и контекста в любой момент и восстанавливайте машину из него позже, не прогоняя заново все guards и actions промежуточных переходов.
Guard блокирует переход, если попыток уже 3, action увеличивает счётчик и сбрасывает ошибку — вся логика формы описана декларативно в одном месте, а не размазана по обработчикам.
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() возвращает промис, дожидающийся конца перехода, can() синхронно проверяет доступность события, isDone включается на финальном состоянии — всё реактивно, без ручных computed.
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 сам строит машину по массиву шагов: canProceed блокирует next(), пока обязательные поля не заполнены, а progress — готов из коробки.
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.