Коротко: Руководство по архитектуре сложных форм и визардов в React на XState v5: акторная модель, setup API, TypeScript, сохранение состояния и изоляция логики.
Разработка многошаговых форм (визардов) в React быстро превращается в хаос, когда логика шагов перестает быть строго линейной. Появление условных экранов, динамических валидаций, асинхронных проверок и необходимости сохранения черновика порождает так называемый «булев ад»: десятки флагов (isLoading, isSubmitting, isCorporateAccount, canGoToStep3), размазанных по хукам useState и useEffect. В результате система переходит в невалидные состояния, которые сложно воспроизвести и покрыть тестами.
Конечные автоматы (Finite State Machines, FSM) и стейтчарты устраняют эту проблему на уровне архитектуры: компонент всегда находится строго в одном из заранее описанных состояний, а переходы возможны только по явно заданным событиям. Библиотека XState v5 переосмыслила работу с FSM во фронтенде, сделав акцент на акторную модель, глубокую интеграцию с TypeScript 5+ и предсказуемую работу с сайд-эффектами.
Анатомия XState v5: что изменилось и почему это важно
Пятая версия XState отошла от устаревших концепций v4 (таких как Machine() и interpret()) в пользу строгой акторной модели (Actor Model) и декларативной инициализации через setup().
Акторная модель и типобезопасный setup() API
В XState v5 стейт-машина — это описание логики поведения (blueprint), а выполняющийся процесс — актор (actor). Актор изолирует свое состояние, общается с окружающим миром через события (messages) и может порождать дочерние процессы.
Главным стандартом создания машин в v5 стал метод setup(). Он решает давнюю проблему типизации в TypeScript без необходимости ручного прописывания громоздких дженериков:
import { setup, assign } from 'xstate';
interface FormContext {
userId: string | null;
accountType: 'personal' | 'company' | null;
stepData: Record<string, unknown>;
}
type FormEvent =
| { type: 'SELECT_TYPE'; accountType: 'personal' | 'company' }
| { type: 'SUBMIT_STEP'; data: Record<string, unknown> }
| { type: 'PREVIOUS' }
| { type: 'RETRY' };
export const formMachine = setup({
types: {
context: {} as FormContext,
events: {} as FormEvent,
},
guards: {
isCompany: ({ context }) => context.accountType === 'company',
},
actions: {
notifyError: () => {
console.error('Ошибка отправки формы');
},
},
}).createMachine({
id: 'formWizard',
initial: 'selectType',
context: {
userId: null,
accountType: null,
stepData: {},
},
states: {
selectType: {
on: {
SELECT_TYPE: {
target: 'fillProfile',
actions: assign({
accountType: ({ event }) => event.accountType,
}),
},
},
},
fillProfile: {
// дальнейшие состояния
},
},
});
Контекст, события и строгая иммутабельность через assign
Контекст (context) отвечает за количественные данные формы (введенные значения полей, ID сущностей, ошибки валидации). В XState v5 контекст доступен только для чтения внутри переходов и условий. Изменение контекста допускается исключительно через встроенное действие assign().
При выполнении assign функция принимает текущий контекст и событие, возвращая обновленный фрагмент данных. Это исключает неявные мутации и гарантирует воспроизводимость истории переходов.
Guards и сайд-эффекты
Управление переходами строится на двух механизмах:
- Guards (условия): чистые функции-предикаты, которые возвращают
trueилиfalse. Если условие не выполняется, переход блокируется. - Actions (действия) и Actors (акторы): сайд-эффекты. Простые действия (fire-and-forget) запускаются при входе в состояние, выходе из него или на переходе. Для длительных асинхронных операций (например, сетевых запросов) используются акторы на базе промисов (
fromPromise) или колбэков (fromCallback).
Проектирование визарда: от схемы состояний к коду
Рассмотрим сценарий многошаговой регистрации:
- Шаг 1: Выбор типа учетной записи (физлицо / юрлицо).
- Шаг 2a (для физлиц): Ввод персональных данных.
- Шаг 2b (для юрлиц): Ввод реквизитов компании и проверка ИНН через API.
- Шаг 3: Отправка данных на сервер и подтверждение.
Моделирование графа состояний
Вместо условных выражений в UI («если isCompany, то рендерим экран B, иначе A»), граф состояний четко описывает ветвление.
[selectType]
/ \
(SELECT: personal) (SELECT: company)
/ \
v v
[personalInfo] [companyInfo]
\ /
(SUBMIT) (SUBMIT)
\ /
v v
[submitting]
/ \
(SUCCESS) (ERROR)
/ \
v v
[completed] [companyInfo / personalInfo]
Реализация машины с асинхронными проверками
Для изоляции асинхронного вызова API вынесем логику верификации компании в отдельный актор через fromPromise.
import { setup, assign, fromPromise } from 'xstate';
interface WizardContext {
accountType: 'personal' | 'company' | null;
personalData: { fullName: string } | null;
companyData: { inn: string; companyName: string } | null;
errorMessage: string | null;
}
type WizardEvent =
| { type: 'CHOOSE_PERSONAL' }
| { type: 'CHOOSE_COMPANY' }
| { type: 'SUBMIT_PERSONAL'; payload: { fullName: string } }
| { type: 'SUBMIT_COMPANY'; payload: { inn: string; companyName: string } }
| { type: 'BACK' }
| { type: 'RETRY' };
const verifyCompanyApi = fromPromise(
async ({ input }: { input: { inn: string } }) => {
const response = await fetch(`/api/verify-inn?inn=${input.inn}`);
if (!response.ok) {
throw new Error('Организация не найдена в реестре');
}
return response.json();
}
);
export const registrationMachine = setup({
types: {
context: {} as WizardContext,
events: {} as WizardEvent,
},
actors: {
verifyCompany: verifyCompanyApi,
},
}).createMachine({
id: 'registrationWizard',
initial: 'selectType',
context: {
accountType: null,
personalData: null,
companyData: null,
errorMessage: null,
},
states: {
selectType: {
on: {
CHOOSE_PERSONAL: {
target: 'personalStep',
actions: assign({ accountType: 'personal' }),
},
CHOOSE_COMPANY: {
target: 'companyStep',
actions: assign({ accountType: 'company' }),
},
},
},
personalStep: {
on: {
SUBMIT_PERSONAL: {
target: 'submitting',
actions: assign({
personalData: ({ event }) => event.payload,
}),
},
BACK: { target: 'selectType' },
},
},
companyStep: {
on: {
SUBMIT_COMPANY: {
target: 'verifyingCompany',
actions: assign({
companyData: ({ event }) => event.payload,
errorMessage: null,
}),
},
BACK: { target: 'selectType' },
},
},
verifyingCompany: {
invoke: {
id: 'verifyCompanyActor',
src: 'verifyCompany',
input: ({ context }) => ({
inn: context.companyData?.inn ?? '',
}),
onDone: {
target: 'submitting',
},
onError: {
target: 'companyStep',
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : 'Ошибка проверки',
}),
},
},
},
submitting: {
initial: 'pending',
states: {
pending: {
after: {
1000: { target: 'success' },
},
},
success: {
type: 'final',
},
},
},
},
});
Интеграция XState v5 с React
Для связки стейт-машины с компонентами используется официальный пакет @xstate/react.
Подключение через useMachine
Хук useMachine принимает машину, инициализирует актор и подписывает компонент на изменения его снапшота (snapshot).
import React from 'react';
import { useMachine } from '@xstate/react';
import { registrationMachine } from './registrationMachine';
export const RegistrationWizard: React.FC = () => {
const [snapshot, send] = useMachine(registrationMachine);
if (snapshot.matches('selectType')) {
return (
<div className="wizard-step">
<h2>Выберите тип аккаунта</h2>
<button onClick={() => send({ type: 'CHOOSE_PERSONAL' })}>
Физическое лицо
</button>
<button onClick={() => send({ type: 'CHOOSE_COMPANY' })}>
Юридическое лицо
</button>
</div>
);
}
if (snapshot.matches('personalStep')) {
return (
<div className="wizard-step">
<h2>Данные физического лица</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const target = e.target as typeof e.target & {
fullName: { value: string };
};
send({
type: 'SUBMIT_PERSONAL',
payload: { fullName: target.fullName.value },
});
}}
>
<input name="fullName" placeholder="ФИО" required />
<button type="button" onClick={() => send({ type: 'BACK' })}>
Назад
</button>
<button type="submit">Далее</button>
</form>
</div>
);
}
if (snapshot.matches('verifyingCompany')) {
return (
<div className="wizard-step">
<p>Идет проверка данных организации в реестре...</p>
</div>
);
}
if (snapshot.matches({ submitting: 'success' })) {
return (
<div className="wizard-step">
<h2>Регистрация успешно завершена!</h2>
</div>
);
}
return null;
};
Разделение ответственности: XState и локальные формы
Частая ошибка при использовании стейт-машин — попытка сохранять каждое нажатие клавиши в контекст машины через событие CHANGE_FIELD. Это приводит к лишним накладным расходам и избыточному коду.
Оптимальное разделение зон ответственности:
- React Hook Form / Formik / Zod: отвечают за локальное состояние конкретного экрана (фокус инпутов, «грязные» поля, валидацию схемы поля на лету).
- XState: отвечает за макро-состояние (какой экран открыт, доступен ли переход дальше, оркестрация запросов к бэкенду, сохранение подтвержденных данных между шагами).
import React from 'react';
import { useForm } from 'react-hook-form';
interface StepProps {
onSuccess: (data: { inn: string; companyName: string }) => void;
onBack: () => void;
serverError: string | null;
}
export const CompanyStepForm: React.FC<StepProps> = ({ onSuccess, onBack, serverError }) => {
const { register, handleSubmit, formState: { errors } } = useForm<{
inn: string;
companyName: string;
}>();
return (
<form onSubmit={handleSubmit(onSuccess)}>
<input {...register('inn', { required: true, minLength: 10 })} placeholder="ИНН" />
{errors.inn && <span>Некорректный ИНН</span>}
<input {...register('companyName', { required: true })} placeholder="Название компании" />
{serverError && <div className="error-alert">{serverError}</div>}
<button type="button" onClick={onBack}>Назад</button>
<button type="submit">Продолжить</button>
</form>
);
};
Сохранение прогресса и восстановление (Persistence)
Если пользователь случайно перезагрузил страницу или закрыл вкладку посреди заполнения сложного визарда, состояние должно восстановиться без повторного выполнения сайд-эффектов.
Снапшоты: разница между getSnapshot() и getPersistedSnapshot()
В XState v5 механизм сохранения был существенно переработан:
actor.getSnapshot()возвращает текущее публичное состояние машины (используется для рендеринга UI).actor.getPersistedSnapshot()возвращает сериализуемое внутреннее состояние машины, включая дерево порожденных (spawned) и запущенных (invoked) акторов (deep persistence).
При восстановлении через createActor(machine, { snapshot: restoredState }):
- Actions не выполняются повторно. Это исключает дублирование аналитических событий или повторные алерты.
- Invocations запускаются заново автоматически. Если процесс был прерван на шаге запроса к API, запрос перезапустится корректно.
Реализация автосохранения визарда в localStorage
import { createActor } from 'xstate';
import { registrationMachine } from './registrationMachine';
const STORAGE_KEY = 'app_registration_wizard_state';
export const initWizardActor = () => {
const savedStateJson = localStorage.getItem(STORAGE_KEY);
let persistedSnapshot;
if (savedStateJson) {
try {
persistedSnapshot = JSON.parse(savedStateJson);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
const actor = createActor(registrationMachine, {
snapshot: persistedSnapshot,
});
actor.subscribe((snapshot) => {
if (snapshot.status === 'active') {
const persisted = actor.getPersistedSnapshot();
localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted));
}
if (snapshot.matches({ submitting: 'success' })) {
localStorage.removeItem(STORAGE_KEY);
}
});
actor.start();
return actor;
};
Когда стейт-машины избыточны: чек-лист для команды
Внедрение XState требует изменения мышления: переход от управления флагами («как сделать») к проектированию модели («в каком состоянии мы находимся»). Не для каждого сценария этот оверхед оправдан.
| Критерий | Достаточно useState / useReducer |
Необходим XState v5 |
|---|---|---|
| Количество шагов | 1–3 линейных экрана | 4+ шагов с ветвлением и возвратами |
| Условная логика | Простая (A -> B -> C) | Сложная (экран C зависит от выбора на шаге A и ответа API на шаге B) |
| Асинхронные цепочки | 1 изолированный fetch | Несколько взаимосвязанных запросов с отменой, таймаутами и повторами |
| Восстановление состояния | Не требуется или тривиально | Критично сохранять точный шаг и незавершенные процессы |
| Тестирование логики | Тесты компонентов через React Testing Library | Unit-тестирование графа состояний в изоляции от React |
FAQ: частые вопросы по XState v5
1. Чем XState v5 принципиально отличается от v4 при работе в React?
В v5 API полностью переработан под TypeScript 5+. Отказ от функции Machine() и интерпретатора interpret() в пользу связки setup() и createActor устранил проблемы с выводом типов. Механизм снапшотов стал более прозрачным, а сохранение состояния (deep persistence) теперь корректно обрабатывает вложенные акторы из коробки.
2. Заменяет ли XState библиотеку React Hook Form?
Нет. Это разные уровни абстракции. React Hook Form решает задачу эффективного управления полями ввода, сбора значений и синхронной/асинхронной валидации на уровне конкретной HTML-формы. XState оркестрирует высокоуровневый процесс: определяет порядок смены экранов, блокирует невалидные переходы и управляет глобальным жизненным циклом сценария.
3. Выполняются ли actions повторно при восстановлении через snapshot?
Нет. При передаче сохраненного снапшота в createActor(machine, { snapshot }) XState восстанавливает контекст и текущее состояние без повторного запуска действий входа (entry actions) и переходов (transition actions).
4. Как протестировать машину состояний без монтирования React-компонентов?
Поскольку логика машины полностью отделена от UI, тесты пишутся на чистом TypeScript:
import { createActor } from 'xstate';
import { formMachine } from './formMachine';
test('переход в fillProfile после выбора типа', () => {
const actor = createActor(formMachine).start();
actor.send({ type: 'SELECT_TYPE', accountType: 'personal' });
const snapshot = actor.getSnapshot();
expect(snapshot.value).toBe('fillProfile');
expect(snapshot.context.accountType).toBe('personal');
});
5. Не раздувает ли XState бандл приложения?
Модульная архитектура v5 оптимизирована под tree-shaking. Если речь идет о сложном продуктовом визарде (онбординг, кредитный калькулятор, оформление заказа с множеством условий), уменьшение количества багов и скорость отладки полностью окупают подключение библиотеки. Для простых одностраничных форм использовать XState не имеет практического смысла.
Заключение
Перевод сложной логики форм на конечные автоматы избавляет проект от скрытого технического долга. Использование XState v5 в связке с React дает три ключевых преимущества:
- Детерминированность: интерфейс физически не может перейти в недопустимое состояние (impossible state).
- Изоляция логики: поведение визарда полностью описано вне React-компонентов, что упрощает их рефакторинг и UI-редизайн.
- Строгая типизация: благодаря
setup()API контекст, события и guards проверяются компилятором TypeScript без необходимости поддержки избыточных интерфейсов вручную.




.svg.webp)





