Управління складними асинхронними процесами за допомогою розрізнених булевих прапорців (isLoading, isSubmitting, hasError, canRetry) неминуче призводить до комбінаторного вибуху невалідних станів. У класичних клієнтських застосунках чотири незалежні прапорці породжують 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 }) |
| |--> Типізація захисників: ({ 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] |
+-------------------------------------------------------------+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 }) => {
// Дискримінована подія: строго звужена до 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;
}Інтерфейс користувача або сокет надсилає типізовану подію до скриньки повідомлень актора.
Актор обчислює предикати 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',
},
},
});У 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: '' };
}Виділіть дискретні стани життєвого циклу (idle, authenticating, active, error) та події переходу.
Опишіть context, events, input через фіктивні приведення (types: {} as { ... }).
Винесіть побічні ефекти у fromPromise або fromCallback всередині секції actors.
Сконфігуруйте переходи, бізнес-правила в guards та оновлення контексту через assign.
Запустіть актор і підключіть його до React-компонентів через хуки @xstate/react.