Skip to Content
👋 Добро пожаловать в документацию lite-fsm!
РуководствоЛучшие практики

Лучшие практики

В этом разделе собраны практические рекомендации по проектированию автоматов, 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-ов имеет смысл, когда домены действительно живут отдельно:

  1. Изоляция доменов: каждый manager отвечает за свой домен и меньше зависит от остальных частей системы.

  2. Разный жизненный цикл: один manager может сохраняться при навигации, другой — пересоздаваться вместе с экраном или проектом.

  3. Отдельные зависимости: сервисы и middleware можно подключать только туда, где они нужны.

  4. Меньше связности: события одного домена не обязаны проходить через машины другого домена.

Работа с эффектами

Избегайте побочных эффектов вне обработчиков 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-е

  1. Предсказуемость — обновление состояния не зависит от внешних сервисов.
  2. Тестируемость — reducer можно проверять изолированно, передавая состояние и action.
  3. Централизация логики — правила обновления состояния находятся рядом с machine config.
  4. Разделение ответственности — 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), что позволяет создать иерархию без использования эффектов для координации. Профиль пользователя автоматически начинает загружаться, когда обнаружена сессия пользователя, и переходит в неактивное состояние при выходе.

Преимущества разделения на несколько автоматов

  1. Меньше сложность каждой машины — домены читаются отдельно.
  2. Переиспользование — независимые автоматы проще перенести в другой сценарий.
  3. Поддерживаемость — изменения в одной машине меньше затрагивают остальные.
  4. Тестирование — автоматы можно проверять независимо друг от друга.
  5. Явные границы доменов — события и 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 на этапе компиляции.

Типизация даёт следующие преимущества:

  1. Автодополнение состояний и событий в IDE
  2. Раннее обнаружение ошибок на этапе разработки
  3. Безопасный рефакторинг с автоматическим отслеживанием всех затронутых мест
  4. Меньше runtime-ошибок, связанных с неправильными переходами
  5. Документирование контракта через типы событий, context shape и dependencies

См. также раздел Работа с TypeScript.

Заключение

Эти практики помогают держать автоматы небольшими, effects явными, а связи между доменами контролируемыми. Главная цель — чтобы состояние приложения можно было прочитать как модель, а не восстанавливать по разрозненным обработчикам.

Last updated on