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

25.05.2026
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 Copy
npm install @macrulez/vue-state-machine

Одна peer-зависимость — Vue 3.3+. Поставляется как tree-shakeable ESM и CommonJS одновременно. DevTools — отдельный entry point, не попадает в продакшн бандл.


defineMachine — конфиг без Vue-зависимости

defineMachine — чистая фабрика конфигурации. Никаких импортов из Vue, никаких хуков. Её можно вызвать и протестировать в Node без монтирования компонента.

ts Copy
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 Copy
<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 Copy
const canRetry = (ctx: { attempts: number }) => ctx.attempts < 3

Action — сайд-эффект на переходе. Может быть async. Возвращает Partial<context> для обновления контекста, либо void для чистых сайд-эффектов.

ts Copy
// Обновляет контекст — 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 Copy
// Безопасно вызывать в быстрой последовательности
await send('SUBMIT')        // дожидаемся завершения
// state здесь уже 'loading'

send('SUCCESS')             // встаёт в очередь
send('RETRY')               // тоже встаёт — но когда дойдёт очередь, state будет 'success' и RETRY будет проигнорирован

Каждый send() возвращает Promise, который резолвится после того как именно это событие полностью обработано. Это делает await send(...) предсказуемым даже с долгими async actions.


Параллельные регионы

Состояние может содержать parallel — набор независимых под-машин, которые все становятся активными одновременно при входе в родительское состояние.

ts Copy
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 Copy
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 Copy
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 Copy
<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 Copy
// Компонент A — где угодно в дереве
const { state } = useSharedMachine(cartMachine)

// Компонент B — в другой ветке
const { send } = useSharedMachine(cartMachine)

// Оба используют один экземпляр
await send('ADD_ITEM')
// state.value в компоненте A обновился реактивно

Требует установки VueMachinePlugin:

ts Copy
// main.ts
import { VueMachinePlugin } from '@macrulez/vue-state-machine'
app.use(VueMachinePlugin)

Persist — сохранение состояния

Снапшот машины (state + context + history) можно персистировать в localStorage. При монтировании компонента снапшот восстанавливается автоматически.

ts Copy
const { state, send } = useMachine(checkoutMachine, {
  persist: { key: 'checkout-v1' },
})
// При каждом переходе — снапшот пишется в localStorage
// При монтировании — снапшот читается и машина восстанавливается

Кастомный storage (sessionStorage, IndexedDB-обёртка — что угодно реализующее интерфейс Storage):

ts Copy
useMachine(machine, {
  persist: { key: 'my-key', storage: sessionStorage },
})

На сервере (typeof window === 'undefined') persist молча отключается — SSR-безопасно без дополнительных проверок.


Сценарии в реальных проектах

Оформление заказа с валидацией по шагам

Форма из четырёх шагов, где каждый следующий шаг нельзя открыть без заполнения предыдущего. Данные корзины переживают перезагрузку страницы. Пользователь может вернуться к любому пройденному шагу.

ts Copy
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 Copy
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 Copy
<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 Copy
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 Copy
<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 занимает несколько минут: createMachinedefineMachine, assign() → plain return, @xstate/vuevue-state-machine. Совместимость с useMachine(machine) сохранена намеренно.


NPM: https://www.npmjs.com/package/@macrulez/vue-state-machine
GitHub: https://github.com/macrulezru/vue-state-machine

Читать далее

24.05.2026

vue-storage-kit — реактивное хранилище для Vue 3 с шифрованием, миграциями и синхронизацией вкладок

Пакет, который закрывает всё что нужно для работы с localStorage, sessionStorage, IndexedDB и cookies во Vue 3 — TTL, AES-GCM шифрование, схема-миграции, кросс-вкладочная синхронизация и Pinia-персист, без единой лишней зависимости.

Метки
vue3typescriptlocalstorageindexeddbpinia
23.05.2026

Command Palette для Vue 3 — fuzzy-поиск, вложенные палитры и глобальные хоткеи из коробки

@macrulez/vue-command-palette — готовый Command+K интерфейс для Vue 3 с fuzzy-поиском, группами, вложенными палитрами, историей команд и полной поддержкой тем. Единственная peer-зависимость — Vue 3.

Метки
vuevue3command-palettetypescriptopen-source