Лучшие практики
В этом разделе собраны практические рекомендации по проектированию автоматов, effects, middleware и структуры приложения.
Примеры reducer-ов на этой странице написаны в стиле Immer: они мутируют draft
stateи предполагают, чтоMachineManagerсоздан сimmerMiddleware. Без middleware reducer должен вернуть новый объект{ state, context }.
Структурируйте большие автоматы
Для сложных автоматов с множеством состояний и переходов вы можете разделять конфигурацию на логические группы:
import { createMachine, createConfig } from "@lite-fsm/core";
// Состояния создания и редактирования
const editingStates = {
DRAFT: {
SUBMIT: "REVIEW",
SAVE: null,
},
EDITING: {
SAVE: null,
DONE: "REVIEW",
},
} as const;
// Состояния проверки
const reviewStates = {
REVIEW: {
APPROVE: "APPROVED",
REJECT: "REJECTED",
EDIT: "EDITING",
},
APPROVED: {
PUBLISH: "PUBLISHED",
EDIT: "EDITING",
},
REJECTED: {
EDIT: "EDITING",
},
} as const;
// Состояния публикации
const publishStates = {
PUBLISHED: {
ARCHIVE: "ARCHIVED",
UNPUBLISH: "DRAFT",
},
ARCHIVED: {
RESTORE: "DRAFT",
},
} as const;
// Объединение в полную конфигурацию
const workflowConfig = createConfig({
...editingStates,
...reviewStates,
...publishStates,
});
// Создание автомата
const workflowMachine = createMachine({
config: workflowConfig,
initialState: "DRAFT",
initialContext: {},
});Примечание: Подробное руководство по композиции автоматов и организации файловой структуры есть в разделе Создание автомата.
Моделирование приложения
Разделяйте ответственность между автоматами
Каждый автомат должен иметь четкую область ответственности. Не пытайтесь моделировать всё приложение одним большим автоматом.
Хорошо: отдельные автоматы для разных функциональных областей
// Автомат для аутентификации
const authMachine = createMachine({
config: {
LOGGED_OUT: { LOGIN: "LOGGING_IN" },
LOGGING_IN: {
LOGIN_SUCCESS: "LOGGED_IN",
LOGIN_ERROR: "LOGIN_ERROR",
},
// ...
},
// ...
});
// Автомат для управления пользовательскими данными
const userDataMachine = createMachine({
config: {
IDLE: { LOAD: "LOADING" },
LOADING: {
LOAD_SUCCESS: "LOADED",
LOAD_ERROR: "ERROR",
},
// ...
},
// ...
});
// Управление через единый менеджер
const manager = MachineManager({
auth: authMachine,
userData: userDataMachine,
});Плохо: один большой автомат с перемешанной ответственностью
const appMachine = createMachine({
config: {
LOGGED_OUT: { LOGIN: "LOGGING_IN" },
LOGGING_IN: {
LOGIN_SUCCESS: "LOGGED_IN_DATA_LOADING",
LOGIN_ERROR: "LOGIN_ERROR",
},
LOGGED_IN_DATA_LOADING: {
DATA_LOADED: "LOGGED_IN_DATA_LOADED",
DATA_ERROR: "LOGGED_IN_DATA_ERROR",
},
// ... смешивание ответственности затрудняет понимание
},
// ...
});Несколько менеджеров
В больших приложениях иногда удобнее использовать несколько независимых MachineManager: например, отдельно для пользовательской сессии и для текущего проекта.
// Автомат авторизации
const authMachine = createMachine({
/* ... */
});
// Автомат профиля пользователя
const profileMachine = createMachine({
/* ... */
});
// Автомат уведомлений
const notificationsMachine = createMachine({
/* ... */
});
// Автомат списка проектов
const projectListMachine = createMachine({
/* ... */
});
// Автомат задач проекта
const tasksMachine = createMachine({
/* ... */
});
// Создание иерархии менеджеров
const userManager = MachineManager({
auth: authMachine,
profile: profileMachine,
notifications: notificationsMachine,
});
const projectManager = MachineManager({
projects: projectListMachine,
tasks: tasksMachine,
});
// Координация между менеджерами
userManager.onTransition((prevState, nextState) => {
// Если пользователь вышел, сбрасываем проекты
if (prevState.auth.state === "LOGGED_IN" && nextState.auth.state === "LOGGED_OUT") {
projectManager.transition({ type: "RESET" });
}
});Когда несколько менеджеров полезны
Один общий manager удобен для небольших и средних приложений. Разделение на несколько manager-ов имеет смысл, когда домены действительно живут отдельно:
-
Изоляция доменов: каждый manager отвечает за свой домен и меньше зависит от остальных частей системы.
-
Разный жизненный цикл: один manager может сохраняться при навигации, другой — пересоздаваться вместе с экраном или проектом.
-
Отдельные зависимости: сервисы и middleware можно подключать только туда, где они нужны.
-
Меньше связности: события одного домена не обязаны проходить через машины другого домена.
Работа с эффектами
Избегайте побочных эффектов вне обработчиков effects
Держите побочные действия в effects, чтобы reducer оставался чистой функцией перехода:
Хорошо:
const machine = createMachine({
config: {
IDLE: { FETCH: "LOADING" },
LOADING: {
FETCH_RESOLVE: "READY",
FETCH_REJECT: "ERROR",
},
READY: {},
ERROR: {
RETRY: "LOADING",
},
},
initialState: "IDLE",
initialContext: { items: [] as string[] },
effects: {
LOADING: async ({ transition, api }) => {
try {
const items = await api.fetchData();
transition({ type: "FETCH_RESOLVE", payload: { items } });
} catch (error) {
transition({
type: "FETCH_REJECT",
payload: { error: error instanceof Error ? error.message : "unknown" },
});
}
},
},
});Плохо:
// Побочные эффекты вне автомата
const fetchData = async () => {
manager.transition({ type: "FETCH" });
try {
const items = await api.fetchData();
manager.transition({ type: "FETCH_RESOLVE", payload: { items } });
} catch (error) {
manager.transition({ type: "FETCH_REJECT", payload: { error: error.message } });
}
};
button.addEventListener("click", fetchData);Используйте сервисный слой для бизнес-логики
Выносите сложную бизнес-логику в сервисы и внедряйте их через setDependencies:
const api = {
fetchData: async () => {
/* реализация API запроса */
},
saveData: async (data) => {
/* реализация сохранения данных */
},
};
const validation = {
validateForm: (data) => {
const errors: Record<string, string> = {};
if (!data.name) errors.name = "Имя обязательно";
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = "Некорректный email";
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
},
};
const storage = {
set: (key: string, value: unknown) => localStorage.setItem(key, JSON.stringify(value)),
get: (key: string) => {
try {
return JSON.parse(localStorage.getItem(key) ?? "null");
} catch {
return null;
}
},
};
manager.setDependencies({ api, validation, storage });
const form = createMachine({
config: {
IDLE: { SUBMIT: "VALIDATING" },
VALIDATING: {
VALID: "SUBMITTING",
INVALID: "VALIDATION_ERROR",
},
SUBMITTING: {
SUBMIT_RESOLVE: "SUCCESS",
SUBMIT_REJECT: "SUBMIT_ERROR",
},
SUCCESS: {},
VALIDATION_ERROR: {},
SUBMIT_ERROR: {},
},
initialState: "IDLE",
initialContext: {},
effects: {
VALIDATING: ({ action, transition, validation }) => {
const result = validation.validateForm(action.payload.data);
if (result.isValid) {
transition({ type: "VALID", payload: { data: action.payload.data } });
} else {
transition({ type: "INVALID", payload: { errors: result.errors } });
}
},
SUBMITTING: async ({ action, transition, api, storage }) => {
try {
await api.saveData(action.payload.data);
storage.set("lastSuccessfulSubmit", Date.now());
transition({ type: "SUBMIT_RESOLVE" });
} catch (error) {
transition({
type: "SUBMIT_REJECT",
payload: { error: error instanceof Error ? error.message : "unknown" },
});
}
},
},
});Управление контекстом
Разделяйте контекст по ответственности
Структурируйте контекст автомата по логическим категориям:
const formMachine = createMachine({
// ...
initialContext: {
// Данные формы
form: {
name: "",
email: "",
age: null,
preferences: [],
},
// Состояние формы
state: {
dirty: false,
touched: {},
submitCount: 0,
},
// Ошибки валидации
validation: {
errors: {},
isValid: true,
},
// Состояние отправки
submission: {
attempted: false,
error: null,
lastSubmitted: null,
},
},
// ...
});Используйте reducer для логики обновления
Держите логику обновления контекста в reducer-е, когда она может быть реализована как чистое преобразование и не нуждается в доступе к сервисам или состоянию других автоматов.
const todo = createMachine({
config: {
IDLE: {
ADD_TODO: null,
UPDATE_TODO: null,
DELETE_TODO: null,
TOGGLE_TODO: null,
FILTER: null,
},
},
initialState: "IDLE",
initialContext: {
todos: [] as { id: string; text: string; completed: boolean; createdAt: string }[],
filter: "all" as "all" | "active" | "completed",
stats: {
total: 0,
completed: 0,
active: 0,
},
},
reducer: (state, action, { nextState }) => {
state.state = nextState;
switch (action.type) {
case "ADD_TODO": {
state.context.todos.push({
id: action.payload.id,
text: action.payload.text,
createdAt: action.payload.createdAt,
completed: false,
});
state.context.stats.total += 1;
state.context.stats.active += 1;
return;
}
case "TOGGLE_TODO": {
const todo = state.context.todos.find((t) => t.id === action.payload.id);
if (!todo) return;
todo.completed = !todo.completed;
state.context.stats.completed = state.context.todos.filter((t) => t.completed).length;
state.context.stats.active = state.context.todos.length - state.context.stats.completed;
return;
}
case "FILTER": {
state.context.filter = action.payload.filter;
return;
}
}
},
});Примечание: Редьюсеры должны быть чистыми функциями. Вызовы таких методов как
Date.now()илиnew Date()создают побочные эффекты и делают редьюсеры непредсказуемыми. Вместо этого генерируйте ID и временные метки вне редьюсера, например, в эффектах или обработчиках событий, и передавайте их через payload:// Правильно: генерация ID и timestamp вне редьюсера const handleAddTodo = (text) => { const now = new Date(); manager.transition({ type: "ADD_TODO", payload: { id: generateId(), // или Date.now() text, createdAt: now.toISOString(), }, }); };
Зачем держать логику в reducer-е
- Предсказуемость — обновление состояния не зависит от внешних сервисов.
- Тестируемость — reducer можно проверять изолированно, передавая состояние и action.
- Централизация логики — правила обновления состояния находятся рядом с machine config.
- Разделение ответственности — reducer обновляет состояние, effects выполняют побочные действия.
Когда использовать редьюсеры, а когда эффекты
Используйте редьюсеры для:
- Всех преобразований данных и обновлений контекста
- Логики, которая не требует доступа к внешним API или сервисам
- Сложных вычислений на основе имеющихся данных в контексте
- Обновления связанных частей контекста (например, обновление статистики при изменении данных)
Используйте эффекты для:
- Асинхронных операций (API запросы, таймеры)
- Доступа к сервисам (аналитика, локальное хранилище)
- Координации между несколькими автоматами
- Сложной последовательности событий, которые должны происходить на определенных этапах
Используйте вложенные и параллельные автоматы для управления сложностью
При моделировании сложных процессов легко получить «взрыв состояний», когда одна машина начинает описывать слишком много независимых комбинаций. В таких случаях стоит разделять процесс на несколько автоматов:
Параллельные автоматы
Параллельные автоматы позволяют разделить сложную систему на несколько независимых автоматов, которые могут реагировать на одни и те же события одновременно:
const order = createMachine({
config: {
IDLE: {
START_CHECKOUT: "PROCESSING",
},
PROCESSING: {
PAYMENT_RESOLVE: "COMPLETED",
PAYMENT_REJECT: "ERROR",
},
ERROR: {
RETRY_PAYMENT: "PROCESSING",
CANCEL: "IDLE",
},
COMPLETED: {},
},
initialState: "IDLE",
initialContext: {},
effects: {
PROCESSING: async ({ transition, payment }) => {
try {
await payment.process();
transition({ type: "PAYMENT_RESOLVE" });
} catch (error) {
transition({ type: "PAYMENT_REJECT", payload: { error } });
}
},
},
});
const notifications = createMachine({
config: {
IDLE: {
START_CHECKOUT: "SHOWING_PROGRESS",
PAYMENT_RESOLVE: "SHOWING_SUCCESS",
PAYMENT_REJECT: "SHOWING_ERROR",
},
SHOWING_PROGRESS: {
HIDE: "IDLE",
PAYMENT_RESOLVE: "SHOWING_SUCCESS",
PAYMENT_REJECT: "SHOWING_ERROR",
},
SHOWING_SUCCESS: {
HIDE: "IDLE",
},
SHOWING_ERROR: {
HIDE: "IDLE",
RETRY_PAYMENT: "SHOWING_PROGRESS",
},
},
initialState: "IDLE",
initialContext: {},
effects: {
SHOWING_SUCCESS: ({ transition }) => {
setTimeout(() => transition({ type: "HIDE" }), 3000);
},
},
});
// Аналитика реагирует на те же события через wildcard
const analytics = createMachine({
config: {
TRACKING: {
START_CHECKOUT: null,
PAYMENT_RESOLVE: null,
PAYMENT_REJECT: null,
RETRY_PAYMENT: null,
},
},
initialState: "TRACKING",
initialContext: {},
effects: {
TRACKING: ({ action, analytics }) => {
analytics.trackEvent(action.type, action.payload);
},
},
});
const manager = MachineManager({ order, notifications, analytics });
// Одно событие — три параллельных реакции
manager.transition({ type: "START_CHECKOUT", payload: { orderId: "12345" } });Ключевое преимущество параллельных автоматов: одно и то же событие (например, START_CHECKOUT) могут обработать несколько машин, каждая в своей области ответственности.
Связанные автоматы
Связанные автоматы позволяют моделировать процессы, где состояние одного домена влияет на другой. В lite-fsm это композиционный паттерн: машины остаются плоскими, но реагируют на общие события.
const auth = createMachine({
config: {
IDLE: {
INIT: "CHECKING_AUTH",
},
CHECKING_AUTH: {
HAS_SESSION: "AUTHENTICATED",
NO_SESSION: "UNAUTHENTICATED",
},
AUTHENTICATED: {
LOGOUT: "UNAUTHENTICATED",
},
UNAUTHENTICATED: {
LOGIN: "LOGIN_PENDING",
},
LOGIN_PENDING: {
LOGIN_RESOLVE: "AUTHENTICATED",
LOGIN_REJECT: "LOGIN_ERROR",
},
LOGIN_ERROR: {
RETRY: "LOGIN_PENDING",
RESET: "UNAUTHENTICATED",
},
},
initialState: "IDLE",
initialContext: {},
});
const profile = createMachine({
config: {
INACTIVE: {
HAS_SESSION: "LOADING",
LOGIN_RESOLVE: "LOADING",
},
LOADING: {
LOAD_RESOLVE: "LOADED",
LOAD_REJECT: "ERROR",
},
LOADED: {
UPDATE: "UPDATING",
LOGOUT: "INACTIVE",
},
UPDATING: {
UPDATE_RESOLVE: "LOADED",
UPDATE_REJECT: "ERROR",
LOGOUT: "INACTIVE",
},
ERROR: {
RETRY: "LOADING",
LOGOUT: "INACTIVE",
},
},
initialState: "INACTIVE",
initialContext: {},
effects: {
LOADING: async ({ api, transition }) => {
try {
const profile = await api.fetchProfile();
transition({ type: "LOAD_RESOLVE", payload: { profile } });
} catch (error) {
transition({ type: "LOAD_REJECT", payload: { error } });
}
},
},
});
const manager = MachineManager({ auth, profile });
manager.transition({ type: "INIT" });
// При обнаружении сессии событие активирует оба автомата:
// - auth переходит в AUTHENTICATED
// - profile из INACTIVE → LOADING и стартует загрузку профиля
manager.transition({ type: "HAS_SESSION" });В этом примере оба автомата напрямую реагируют на одни и те же события (HAS_SESSION, LOGIN_RESOLVE, LOGOUT), что позволяет создать иерархию без использования эффектов для координации. Профиль пользователя автоматически начинает загружаться, когда обнаружена сессия пользователя, и переходит в неактивное состояние при выходе.
Преимущества разделения на несколько автоматов
- Меньше сложность каждой машины — домены читаются отдельно.
- Переиспользование — независимые автоматы проще перенести в другой сценарий.
- Поддерживаемость — изменения в одной машине меньше затрагивают остальные.
- Тестирование — автоматы можно проверять независимо друг от друга.
- Явные границы доменов — события и dependencies показывают, как процессы связаны.
См. также раздел Параллельные автоматы.
Отладка и тестирование
Создайте мок-сервисы для тестирования
Для упрощения тестирования создавайте мок-версии сервисов:
const realApi = {
fetchData: async () => {
/* реальная реализация */
},
};
const mockApi = {
fetchData: async () => ({ items: [{ id: 1, name: "Test" }] }),
};
const errorMockApi = {
fetchData: async () => {
throw new Error("Network error");
},
};
test("should handle successful data fetch", async () => {
const manager = MachineManager({ data });
manager.setDependencies({ api: mockApi });
manager.transition({ type: "FETCH" });
await vi.waitFor(() => {
return manager.getState().data.state === "READY";
});
const { state, context } = manager.getState().data;
expect(state).toBe("READY");
expect(context.items).toEqual([{ id: 1, name: "Test" }]);
});Интеграция в приложение
Организуйте масштабируемую структуру проекта
Для больших приложений следуйте четкой структуре организации автоматов:
src/
├── machines/ # Определения автоматов
│ ├── auth/
│ │ ├── constants.ts # Константы для состояний и событий
│ │ ├── types.ts # Типы для TypeScript
│ │ ├── machine.ts # Определение автомата
│ │ └── index.ts # Публичный API
│ ├── user/
│ ├── products/
│ └── index.ts # Экспорт всех автоматов
│
├── services/ # Сервисный слой
│ ├── api.ts
│ ├── validation.ts
│ ├── storage.ts
│ └── index.ts
│
├── store/ # Интеграция с общим хранилищем приложения
│ ├── manager.ts # Создание и настройка MachineManager
│ ├── hooks.ts # React хуки для доступа к состоянию
│ └── index.ts
│
└── components/ # Компоненты UI
├── auth/
├── user/
└── products/Используйте полную типизацию с TypeScript
В TypeScript-проектах создавайте типизированные версии фабрик и хуков lite-fsm. Это даёт проверку событий, payload-ов, состояний и формы context на этапе компиляции.
Типизация даёт следующие преимущества:
- Автодополнение состояний и событий в IDE
- Раннее обнаружение ошибок на этапе разработки
- Безопасный рефакторинг с автоматическим отслеживанием всех затронутых мест
- Меньше runtime-ошибок, связанных с неправильными переходами
- Документирование контракта через типы событий, context shape и dependencies
См. также раздел Работа с TypeScript.
Заключение
Эти практики помогают держать автоматы небольшими, effects явными, а связи между доменами контролируемыми. Главная цель — чтобы состояние приложения можно было прочитать как модель, а не восстанавливать по разрозненным обработчикам.