vue-state-machine — конечные автоматы для Vue 3 без XState

Каждое нетривиальное Vue-приложение в какой-то момент обрастает флагами. isLoading, isSubmitting, hasError, isSuccess. Они разбросаны по ref-ам, логика переходов продублирована между методами, и через месяц никто не может с уверенностью сказать — в каких комбинациях эти флаги вообще могут быть одновременно. isLoading = true при isSuccess = true — это баг или фича?
Конечный автомат решает эту проблему на уровне модели: вместо набора флагов — список состояний и явные переходы между ними. Машина не может быть в двух состояниях сразу. Переход без правила невозможен. Логика собрана в одном месте и читается как схема.
XState — правильный инструмент, но тяжёлый. Свой язык конфигурации, акторная модель, assign(), invoke, spawn, interpret. Для простых кейсов это избыточно, а кривая обучения крутая.
Написал vue-state-machine — минималистичную библиотеку FSM для Vue 3. Декларативные состояния, guard-ы, actions, параллельные регионы, persist, useWizard. Без лишней абстракции, с полным TypeScript и XState-совместимым подмножеством API. Ядро — 4 KB gzip.
Установка
bash
npm install @macrulez/vue-state-machine
Одна peer-зависимость — Vue 3.3+. Поставляется как tree-shakeable ESM и CommonJS одновременно. DevTools — отдельный entry point, не попадает в продакшн бандл.
defineMachine — конфиг без Vue-зависимости
defineMachine — чистая фабрика конфигурации. Никаких импортов из Vue, никаких хуков. Её можно вызвать и протестировать в Node без монтирования компонента.
ts
import { defineMachine } from '@macrulez/vue-state-machine'
const loginMachine = defineMachine({
id: 'login',
initial: 'idle',
context: {
attempts: 0,
error: null as string | 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' },
},
})
TypeScript выводит TState, TEvent и TContext прямо из конфига. state будет Ref<'idle' | 'loading' | 'error' | 'success'>, send принимает только допустимые события — передать несуществующую строку это ошибка компиляции.
В development-режиме defineMachine бросает понятные ошибки: пустой id, initial не в списке states, target ссылается на несуществующее состояние. В продакшне вся валидация tree-shaken.
useMachine — реактивная обёртка
useMachine оборачивает конфиг в Vue-реактивность и возвращает API для работы с машиной из шаблона и setup.
vue
<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, can, matches, isDone, history } = useMachine(trafficLight)
</script>
<template>
<div :class="state">
<p>Сейчас: {{ state }}</p>
<button @click="send('NEXT')">Следующий</button>
</div>
</template>
state — реактивный Ref<TState>. Изменился — компонент перерисовался. Без watch, без emit, без store.
Полный список того, что возвращает useMachine:
| Свойство | Тип | Описание |
|---|---|---|
state |
Readonly<Ref<TState>> |
Текущее состояние — реактивно |
context |
Readonly<Ref<TContext>> |
Контекст машины — реактивно |
send |
(event) => Promise<void> |
Отправить событие в очередь |
matches |
(query) => boolean |
Проверить состояние или регион |
can |
(event) => boolean |
Сработает ли переход (guard вычисляется синхронно) |
isDone |
ComputedRef<boolean> |
true когда текущее состояние type: 'final' |
history |
Readonly<Ref<TransitionRecord[]>> |
История переходов, FIFO с лимитом |
snapshot |
ComputedRef<MachineSnapshot> |
Сериализуемый снапшот для persist/restore |
restore |
(snapshot) => void |
Восстановить состояние без выполнения guard и actions |
Guards и Actions — логика переходов
Guard — синхронный предикат. Возвращает boolean. Если false — переход не происходит. Если бросает исключение — тоже false. Важно: guard должен быть чистой функцией без сайд-эффектов, потому что can() вызывает его реактивно при каждом рендере.
ts
const canRetry = (ctx: { attempts: number }) => ctx.attempts < 3
Action — сайд-эффект на переходе. Может быть async. Возвращает Partial<context> для обновления контекста, либо void для чистых сайд-эффектов.
ts
// Обновляет контекст — returns Partial
const incrementAttempts = (ctx: { attempts: number }) => ({
attempts: ctx.attempts + 1,
})
// Асинхронный action — fetch внутри, результат мёрджится в контекст
const loadUser = async (ctx, event: { type: 'LOAD'; id: number }) => {
const user = await api.getUser(event.id)
return { user }
}
// Сайд-эффект без возврата
const logTransition = (ctx, event) => {
analytics.track('state_changed', { event: event.type })
}
Порядок выполнения при переходе фиксированный: exit текущего состояния → actions перехода → entry следующего состояния. Каждый action await-ится перед следующим.
Event Queue — никаких гонок с async
send() не выполняет переход напрямую — добавляет событие в очередь. Очередь обрабатывается последовательно: следующее событие не начинает обработку пока не завершится предыдущее (включая все async actions). Это устраняет целый класс гонок.
ts
// Безопасно вызывать в быстрой последовательности
await send('SUBMIT') // дожидаемся завершения
// state здесь уже 'loading'
send('SUCCESS') // встаёт в очередь
send('RETRY') // тоже встаёт — но когда дойдёт очередь, state будет 'success' и RETRY будет проигнорирован
Каждый send() возвращает Promise, который резолвится после того как именно это событие полностью обработано. Это делает await send(...) предсказуемым даже с долгими async actions.
Параллельные регионы
Состояние может содержать parallel — набор независимых под-машин, которые все становятся активными одновременно при входе в родительское состояние.
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() рассылает событие во все активные регионы. Каждый регион обрабатывает его независимо. matches() умеет проверять состояние конкретного региона:
ts
const { matches, send } = useMachine(editor)
matches('editing') // главное состояние
matches({ saving: 'idle' }) // регион saving
matches({ validation: 'valid' }) // регион validation
await send('INVALIDATE')
matches({ validation: 'invalid' }) // true
matches({ saving: 'idle' }) // true — этот регион не затронут
Если два региона вернут Partial<context> с одним и тем же полем — побеждает последний объявленный. В dev-режиме в консоль летит console.warn с именами конфликтующих регионов.
useWizard — многошаговые формы
useWizard — composable для пошаговых форм, построенный поверх useMachine. Конфиг машины генерируется автоматически из массива шагов.
ts
import { useWizard } from '@macrulez/vue-state-machine'
const { currentStep, progress, isFirst, isLast, next, prev } = useWizard([
{
id: 'info',
label: 'Контакты',
canProceed: (ctx) => !!ctx.name && !!ctx.email,
onEnter: () => trackEvent('step_info_entered'),
},
{
id: 'address',
label: 'Доставка',
canProceed: async (ctx) => {
// canProceed может быть async — например проверить адрес через API
return await addressApi.validate(ctx.address)
},
},
{
id: 'payment',
label: 'Оплата',
onLeave: () => clearSensitiveData(),
},
])
canProceed проверяется при каждом вызове next() и forward-goTo(). Может быть синхронной или асинхронной. Если возвращает false или бросает — визард остаётся на текущем шаге, next() возвращает false. prev() и backward-goTo() guard не проверяют.
vue
<template>
<div>
<progress :value="progress" max="1" />
<component :is="currentStep.component" />
<nav>
<button :disabled="isFirst" @click="prev">Назад</button>
<button v-if="!isLast" @click="next">Далее</button>
<button v-else @click="submit">Оформить</button>
</nav>
</div>
</template>
Опции: circular (последний шаг next() перебрасывает на первый), allowSkip (отключает canProceed для goTo()), initialStep (стартовый индекс).
useSharedMachine — синглтон без Pinia
useSharedMachine создаёт или возвращает уже существующий экземпляр машины по config.id. Несвязанные компоненты могут разделять одну машину без prop-drilling и без стора.
ts
// Компонент A — где угодно в дереве
const { state } = useSharedMachine(cartMachine)
// Компонент B — в другой ветке
const { send } = useSharedMachine(cartMachine)
// Оба используют один экземпляр
await send('ADD_ITEM')
// state.value в компоненте A обновился реактивно
Требует установки VueMachinePlugin:
ts
// main.ts
import { VueMachinePlugin } from '@macrulez/vue-state-machine'
app.use(VueMachinePlugin)
Persist — сохранение состояния
Снапшот машины (state + context + history) можно персистировать в localStorage. При монтировании компонента снапшот восстанавливается автоматически.
ts
const { state, send } = useMachine(checkoutMachine, {
persist: { key: 'checkout-v1' },
})
// При каждом переходе — снапшот пишется в localStorage
// При монтировании — снапшот читается и машина восстанавливается
Кастомный storage (sessionStorage, IndexedDB-обёртка — что угодно реализующее интерфейс Storage):
ts
useMachine(machine, {
persist: { key: 'my-key', storage: sessionStorage },
})
На сервере (typeof window === 'undefined') persist молча отключается — SSR-безопасно без дополнительных проверок.
Сценарии в реальных проектах
Оформление заказа с валидацией по шагам
Форма из четырёх шагов, где каждый следующий шаг нельзя открыть без заполнения предыдущего. Данные корзины переживают перезагрузку страницы. Пользователь может вернуться к любому пройденному шагу.
ts
interface CheckoutCtx {
email: string
address: { city: string; street: string }
delivery: 'standard' | 'express' | null
cardToken: string | null
}
const { currentStep, currentIndex, progress, next, prev, goTo } = useWizard<CheckoutCtx>(
[
{
id: 'contacts',
label: 'Контакты',
component: StepContacts,
canProceed: (ctx) => isValidEmail(ctx.email),
},
{
id: 'address',
label: 'Адрес',
component: StepAddress,
canProceed: async (ctx) => {
// Проверяем адрес через DADATA API
const result = await dadataApi.checkAddress(ctx.address)
return result.isValid
},
},
{
id: 'delivery',
label: 'Доставка',
component: StepDelivery,
canProceed: (ctx) => ctx.delivery !== null,
},
{
id: 'payment',
label: 'Оплата',
component: StepPayment,
onEnter: () => analytics.track('payment_step_entered'),
onLeave: () => {
// Очищаем данные карты при уходе со шага
ctx.cardToken = null
},
},
],
{ allowSkip: false },
)
// В шаблоне — кнопки навигации блокируются автоматически
// next() вернёт false если canProceed вернул false или бросил ошибку
async function handleNext() {
const ok = await next()
if (!ok) {
showValidationError()
}
}
// Прыгаем назад к контактам — canProceed не проверяется
goTo('contacts')
Прогресс-бар — progress.value от 0 до 1. Индикатор шага — currentIndex.value. Кнопка «Далее» делает await next() и реагирует на результат.
Async форма с retry-логикой и guard-ом
Форма логина с ограниченным числом попыток. После трёх неудач кнопка retry блокируется. State machine делает это явным — невозможно случайно попасть в недопустимую комбинацию флагов.
ts
type LoginCtx = { attempts: number; error: string | null; userId: number | null }
type LoginEvent = 'SUBMIT' | 'SUCCESS' | 'FAILURE' | 'RETRY'
const resetError: Action<LoginCtx, LoginEvent> = () => ({ error: null })
const incrementAttempts: Action<LoginCtx, LoginEvent> = (ctx) => ({ attempts: ctx.attempts + 1 })
const canRetry: Guard<LoginCtx, LoginEvent> = (ctx) => ctx.attempts < 3
const loginMachine = defineMachine<
'idle' | 'loading' | 'error' | 'success',
LoginEvent,
LoginCtx
>({
id: 'login',
initial: 'idle',
context: { attempts: 0, error: null, userId: 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' },
},
})
const { state, context, send, can, isDone } = useMachine(loginMachine)
async function handleSubmit(email: string, password: string) {
await send('SUBMIT') // machine → loading
try {
const { userId } = await authApi.login({ email, password })
await send({ type: 'SUCCESS', userId })
} catch (e) {
await send({ type: 'FAILURE', error: e.message })
}
}
vue
<template>
<form @submit.prevent="handleSubmit(email, password)">
<p v-if="state === 'error'" class="error">
{{ context.error }} (попытка {{ context.attempts }}/3)
</p>
<input v-model="email" :disabled="state === 'loading'" />
<input v-model="password" type="password" :disabled="state === 'loading'" />
<button type="submit" :disabled="state === 'loading'">
{{ state === 'loading' ? 'Входим...' : 'Войти' }}
</button>
<button
v-if="state === 'error'"
@click="send('RETRY')"
:disabled="!can('RETRY')"
>
Попробовать снова
</button>
<p v-if="isDone">Вход выполнен!</p>
</form>
</template>
can('RETRY') вычисляется реактивно — кнопка автоматически заблокируется после третьей ошибки без дополнительного кода в шаблоне. isDone становится true как только машина входит в success (тип 'final').
Редактор с параллельным состоянием сохранения
Редактор документа с двумя независимыми регионами: состояние сохранения (idle → saving → saved) и состояние валидации (valid ↔ invalid). Оба работают параллельно внутри editing, не мешая друг другу.
ts
const editorMachine = defineMachine({
id: 'document-editor',
initial: 'editing',
context: { title: '', content: '', lastSavedAt: null as Date | null },
states: {
editing: {
parallel: {
saving: {
initial: 'idle',
states: {
idle: { on: { SAVE: { target: 'saving' } } },
saving: { on: { SAVE_DONE: { target: 'saved' },
SAVE_FAIL: { target: 'idle' } } },
saved: { on: { EDIT: { target: 'idle' } } },
},
},
validation: {
initial: 'valid',
states: {
valid: { on: { INVALIDATE: { target: 'invalid' } } },
invalid: { on: { VALIDATE: { target: 'valid' } } },
},
},
},
},
archived: { type: 'final' },
},
})
const { state, matches, send } = useMachine(editorMachine)
// Автосохранение при изменении контента
const debouncedSave = useDebounceFn(async () => {
if (matches({ saving: 'idle' })) {
await send('SAVE')
try {
await documentApi.save(documentId, content.value)
await send('SAVE_DONE')
} catch {
await send('SAVE_FAIL')
}
}
}, 1000)
watch(content, () => {
// Отмечаем что контент изменился — нужна валидация
send('INVALIDATE')
debouncedSave()
})
vue
<template>
<div class="editor">
<header>
<span v-if="matches({ saving: 'saving' })">Сохранение...</span>
<span v-else-if="matches({ saving: 'saved' })">Сохранено</span>
<span v-if="matches({ validation: 'invalid' })" class="badge-warn">
Есть ошибки
</span>
</header>
<textarea v-model="content" />
</div>
</template>
Два региона работают независимо. Пока идёт сохранение — событие INVALIDATE меняет регион валидации, но не влияет на saving. Это невозможно закодировать чисто с отдельными флагами без явного управления взаимодействием.
Почему не XState
XState — правильный инструмент для сложной акторной архитектуры. Но invoke, spawn, interpret, assign — это отдельный язык поверх JavaScript, который нужно знать. Кривая обучения видна сразу.
vue-state-machine покрывает 90% кейсов без этого: состояния, переходы, guard, async actions, параллельные регионы, визарды, persist, история. Если машина — это способ организовать логику компонента, а не центр архитектуры всего приложения — здесь хватит возможностей.
Переход с XState занимает несколько минут: createMachine → defineMachine, assign() → plain return, @xstate/vue → vue-state-machine. Совместимость с useMachine(machine) сохранена намеренно.
NPM: https://www.npmjs.com/package/@macrulez/vue-state-machine
GitHub: https://github.com/macrulezru/vue-state-machine