Управление сложными асинхронными процессами через разрозненные булевы флаги (isLoading, isSubmitting, hasError, canRetry) неизбежно приводит к комбинаторному взрыву невалидных состояний. В классических UI-приложениях 4 независимых флага порождают 16 потенциальных состояний, большинство из которых являются логически невозможными или дефектными (например, одновременная истинность isLoading === true и isSuccess === true).
Математическим решением этой проблемы выступает теория конечных автоматов (Finite State Machines, FSM) и диаграмм состояний Харела (Statecharts). XState v5 представляет собой полную архитектурную перезагрузку библиотеки на основе акторной модели (Actor Model).
В XState v4 типизация требовала использования внешнего фонового CLI-генератора типов (@xstate/cli), который создавал промежуточные файлы *.typegen.d.ts. В XState v5 эта громоздкая инфраструктура была полностью устранена благодаря встроенному инференсу типов на базе функции setup(). Теперь вся типизация контекста, событий, акторов, защитников (guards) и действий (actions) вычисляется компилятором TypeScript синхронно без внешних кодогенераторов.
+-------------------------------------------------------------------------+
| АРХИТЕКТУРА ИНФЕРЕНСА ТИПОВ В XSTATE V5 |
+-------------------------------------------------------------------------+
| [setup({ types: {} as { context, events, input }, actors, actions })] |
| |--> Единая точка декларации контрактов автоматов |
| |--> Автоматический вывод типов без CLI typegen файлов |
+-------------------------------------------------------------------------+
|
v
| [machine = setup(...).createMachine({ ... })] |
| |--> Строгая проверка целевых состояний (states: { idle, loading }) |
| |--> Типизация guard(({ context, event }) => boolean) |
| `-- Типизация assign(({ context, event }) => nextContext) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [actor = createActor(machine, { input: { initialBudget: 100 } })] |
| |-- actor.start() |
| |-- actor.send({ type: 'SUBMIT', payload: 'data' }) |
| `-- actor.getSnapshot().matches({ active: 'processing' }) |
+-------------------------------------------------------------------------+В парадигме XState v5 каждое приложение или независимый сервис рассматривается как система общающихся акторов:
fromPromise, fromObservable, fromCallback, fromTransition).Схема акторного взаимодействия в XState v5:
+-------------------------------------------------------------+
| Корневой Актор: CheckoutMachine |
| |
| [Состояние: validating] |
| | |
| | invoke: fromPromise(verifyCardApi) |
| v |
| +---------------------------------------------------------+ |
| | Дочерний Актор: CardVerifier (Promise) | |
| | Выполняет сетевой запрос к платежному шлюзу | |
| +---------------------------------------------------------+ |
| | |
| |-- onDone: assign(cardData) -> переход в [ready] |
| `-- onError: assign(error) -> переход в [rejected] |
+-------------------------------------------------------------+Выделите дискретные состояния сущности (idle, loading, active, error) и события, вызывающие переходы между ними.
Объявите структуры context, events, input через фиктивные касты (types: {} as { ... }).
Вынесите асинхронные сетевые вызовы в секцию actors функции setup() для их типизированного подключения через invoke.
Опишите переходы, примените guards для проверки бизнес-правил и assign для обновления контекста.
Запустите актор в приложении или подключите его к компонентам React через хук @xstate/react (useActorRef / useSelector).
Почему функция setup() в XState v5 полностью устранила потребность во внешнем генераторе типов (@xstate/cli typegen)?
Disconnected -> Connecting -> Authenticating -> Connected -> Reconnecting с экспоненциальной задержкой.setup()#Создадим конечный автомат процесса аутентификации с таймером сессии и обработкой ошибок:
import { setup, assign, fromPromise } from 'xstate';
// 1. Описание структур данных контекста и событий
export interface UserSession {
readonly userId: string;
readonly email: string;
readonly role: 'admin' | 'user';
}
export interface AuthContext {
readonly session: UserSession | null;
readonly errorMessage: string | null;
readonly retryCount: number;
}
export type AuthEvent =
| { type: 'LOGIN'; readonly email: string; readonly pass: string }
| { type: 'LOGOUT' }
| { type: 'RETRY' };
export interface AuthInput {
readonly defaultRetryLimit: number;
}
// 2. Актор асинхронной авторизации
const loginService = fromPromise<
UserSession,
{ readonly email: string; readonly pass: string }
>(async ({ input }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`[AuthError] Неверные учетные данные (${response.status})`);
}
return (await response.json()) as UserSession;
});
// 3. Конфигурация через setup() со сквозной типизацией
export const authMachine = setup({
types: {
context: {} as AuthContext,
events: {} as AuthEvent,
input: {} as AuthInput,
},
actors: {
loginService,
},
guards: {
canRetry: ({ context }, params: { limit: number }) =>
context.retryCount < params.limit,
},
actions: {
notifyAdmin: ({ context }) => {
console.warn(`[Security Alert] Исчерпаны попытки входа: ${context.errorMessage}`);
},
},
}).createMachine({
id: 'authentication',
initial: 'idle',
context: ({ input }) => ({
session: null,
errorMessage: null,
retryCount: 0,
}),
states: {
idle: {
on: {
LOGIN: {
target: 'authenticating',
actions: assign({
errorMessage: () => null,
}),
},
},
},
authenticating: {
invoke: {
id: 'loginActor',
src: 'loginService',
input: ({ event }) => {
// TypeScript строго знает, что event в этой ветке — это LOGIN!
if (event.type !== 'LOGIN') {
throw new Error('Ожидалось событие LOGIN');
}
return { email: event.email, pass: event.pass };
},
onDone: {
target: 'authenticated',
actions: assign({
session: ({ event }) => event.output,
retryCount: () => 0,
}),
},
onError: {
target: 'failure',
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : 'Неизвестный сбой',
retryCount: ({ context }) => context.retryCount + 1,
}),
},
},
},
authenticated: {
on: {
LOGOUT: {
target: 'idle',
actions: assign({
session: () => null,
}),
},
},
},
failure: {
on: {
RETRY: [
{
guard: { type: 'canRetry', params: { limit: 3 } },
target: 'idle',
},
{
target: 'locked',
actions: 'notifyAdmin',
},
],
},
},
locked: {
type: 'final',
},
},
});В XState v5 актор создается функцией createActor(), принимающей экземпляр машины и входные параметры input:
import { createActor } from 'xstate';
import { authMachine } from '../machines/authMachine';
export function initializeAuthSystem() {
// Инициализация актора с передачей обязательного input
const actor = createActor(authMachine, {
input: { defaultRetryLimit: 3 },
});
// Подписка на изменение снимков состояния
actor.subscribe((snapshot) => {
console.log(`Текущее состояние: ${String(snapshot.value)}`);
// snapshot.matches() строго типизирован по именам состояний!
if (snapshot.matches('authenticated')) {
console.log('Пользователь успешно вошел:', snapshot.context.session?.email);
} else if (snapshot.matches('locked')) {
console.error('Учетная запись заблокирована!');
}
});
actor.start();
// Отправка строго проверенных компилятором событий
actor.send({
type: 'LOGIN',
email: '[email protected]',
pass: 'Secr3t!P@ssw0rd',
});
return actor;
}Интерфейс пользователя или внешний WebSocket отправляет строго типизированный объект события в почтовый ящик актора.
Актор вычисляет предикаты guards. При ложном результате переход отклоняется либо активируется альтернативная ветка.
Функции assign формируют обновленный объект контекста без мутации предыдущего состояния памяти.
Актор переходит в новое целевое состояние, запускает связанные сервисы (invoke) и уведомляет подписчиков о новом Snapshot.
import { setup, assign, fromCallback } from 'xstate';
export interface CountdownContext {
readonly remainingSeconds: number;
readonly initialSeconds: number;
}
export type CountdownEvent =
| { type: 'START' }
| { type: 'PAUSE' }
| { type: 'RESET' }
| { type: 'TICK' };
export interface CountdownInput {
readonly durationSeconds: number;
}
// Актор интервального таймера на базе fromCallback
const timerService = fromCallback<CountdownEvent>(({ sendBack }) => {
const intervalId = setInterval(() => {
sendBack({ type: 'TICK' });
}, 1000);
return () => {
clearInterval(intervalId);
};
});
export const countdownMachine = setup({
types: {
context: {} as CountdownContext,
events: {} as CountdownEvent,
input: {} as CountdownInput,
},
actors: {
timerService,
},
}).createMachine({
id: 'countdown',
initial: 'idle',
context: ({ input }) => ({
remainingSeconds: input.durationSeconds,
initialSeconds: input.durationSeconds,
}),
states: {
idle: {
on: {
START: 'running',
RESET: {
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
running: {
invoke: {
id: 'timerTicker',
src: 'timerService',
},
on: {
PAUSE: 'paused',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
TICK: [
{
guard: ({ context }) => context.remainingSeconds <= 1,
target: 'completed',
actions: assign({ remainingSeconds: () => 0 }),
},
{
actions: assign({
remainingSeconds: ({ context }) => context.remainingSeconds - 1,
}),
},
],
},
},
paused: {
on: {
START: 'running',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
completed: {
type: 'final',
},
},
});Какое поведение произойдет, если попытаться напрямую мутировать объект контекста (context.retryCount++) внутри функции action без вызова assign?
В XState v5 использование старого синтаксиса с прямым перечислением дженериков createMachine<Context, Event> лишает автомат автоматического вывода типов действий, акторов и инпутов, приводя к ошибкам any в параметрах действий.
// ❌ АНТИПАТТЕРН: Устаревший синтаксис v4
const badMachine = createMachine<Context, Event>({ ... });
// ✅ ПРАВИЛЬНО: XState v5 стандарт через setup()
const goodMachine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
}).createMachine({ ... });Объект context является строго неизменяемым (Frozen). Попытка выполнить context.count++ вызовет ошибку в рантайме в строгом режиме или приведет к тому, что актор не зафиксирует изменение состояния.
// ❌ АНТИПАТТЕРН: Прямая мутация контекста
actions: ({ context }) => {
context.retryCount += 1; // ОШИБКА: Мутация неизменяемого контекста!
}
// ✅ ПРАВИЛЬНО: Использование функции assign
actions: assign({
retryCount: ({ context }) => context.retryCount + 1,
})В акторной модели экземпляр актора, созданный через createActor(machine), находится в неактивном состоянии. Попытка отправить событие через actor.send() до вызова actor.start() будет проигнорирована.
// ❌ АНТИПАТТЕРН: Отправка событий в незапущенный актор
const actor = createActor(machine);
actor.send({ type: 'EVENT' }); // СОБЫТИЕ ПОТЕРЯНО!
// ✅ ПРАВИЛЬНО: Запуск актора перед отправкой сообщений
const actor = createActor(machine);
actor.start();
actor.send({ type: 'EVENT' });Внутри invoke.input: ({ event }) => ... свойство event представляет собой объединение всех возможных событий, способных привести к данному состоянию. Если вход в состояние возможен по нескольким событиям, обращение к полям конкретного события без дискриминирующей проверки вызовет ошибку компилятора TS2339.
// ✅ ПРАВИЛЬНО: Защитная проверка типа входящего события
input: ({ event }) => {
if (event.type === 'LOGIN') {
return { email: event.email };
}
return { email: '' };
}