Лучшие практики
В этом разделе собраны рекомендации и советы по эффективному использованию lite-fsm для создания надежных и масштабируемых приложений.
Структурируйте большие автоматы
Для сложных автоматов с множеством состояний и переходов вы можете разделять конфигурацию на логические группы:
import { createMachine, createConfig } from "lite-fsm";
// Состояния создания и редактирования
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: {},
});Примечание: Подробное руководство по композиции автоматов, организации файловой структуры и дополнительные примеры вы найдете в разделе Создание автомата. Там показаны различные техники модульного создания как для JavaScript, так и для TypeScript.
Моделирование приложения
Разделяйте ответственность между автоматами
Каждый автомат должен иметь четкую область ответственности. Не пытайтесь моделировать всё приложение одним большим автоматом.
Хорошо: Отдельные автоматы для различных функциональных областей
// Автомат для аутентификации
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",
},
// ... смешивание ответственности затрудняет понимание
},
// ...
});Микросервисная архитектура и распределенные автоматы
В очень сложных приложениях или приложениях с микросервисной архитектурой использование нескольких независимых менеджеров автоматов может предоставлять дополнительные преимущества:
// Автомат авторизации
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" });
}
});Преимущества использования нескольких менеджеров вместо одного
Хотя использование одного глобального менеджера автоматов может быть удобным для небольших и средних приложений, разделение на несколько менеджеров имеет следующие преимущества:
-
Изоляция доменов: Каждый менеджер отвечает за свой домен и не зависит от других частей системы, что упрощает разработку и тестирование
-
Масштабируемость: Независимые менеджеры могут работать на разных серверах или в разных процессах, что улучшает производительность
-
Устойчивость к сбоям: Сбой в одном домене не обязательно приведет к сбою всей системы
-
Оптимизация памяти: Каждый клиент или сервис может загружать только необходимые ему автоматы
Работа с эффектами
Избегайте побочных эффектов вне обработчиков effects
Все побочные эффекты должны быть инкапсулированы в обработчиках effects:
Хорошо:
const machine = createMachine({
config: {
IDLE: { FETCH: "LOADING" },
LOADING: {
FETCH_SUCCESS: "SUCCESS",
FETCH_ERROR: "ERROR",
},
// ...
},
effects: {
LOADING: async ({ transition, services }) => {
try {
const data = await services.api.fetchData();
transition({ type: "FETCH_SUCCESS", payload: { data } });
} catch (error) {
transition({ type: "FETCH_ERROR", payload: { error: error.message } });
}
},
},
});Плохо:
// Побочные эффекты вне автомата
const fetchData = async () => {
manager.transition({ type: "FETCH" });
try {
const data = await api.fetchData();
manager.transition({ type: "FETCH_SUCCESS", payload: { data } });
} catch (error) {
manager.transition({ type: "FETCH_ERROR", payload: { error: error.message } });
}
};
// Использование
button.addEventListener("click", fetchData);Используйте сервисный слой для бизнес-логики
Выносите сложную бизнес-логику в сервисы и внедряйте их через setDependencies:
// Определение сервисов
const services = {
api: {
fetchData: async () => {
// Реализация API запроса
},
saveData: async (data) => {
// Реализация сохранения данных
},
},
validation: {
validateForm: (data) => {
// Проверка формы
const errors = {};
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,
};
},
},
storage: {
saveToLocalStorage: (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
},
getFromLocalStorage: (key) => {
try {
return JSON.parse(localStorage.getItem(key) || "null");
} catch {
return null;
}
},
},
};
// Установка сервисов
manager.setDependencies({ services });
// Использование в эффектах
const formMachine = createMachine({
config: {
IDLE: { SUBMIT: "VALIDATING" },
VALIDATING: {
VALID: "SUBMITTING",
INVALID: "VALIDATION_ERROR",
},
// ...
},
effects: {
VALIDATING: ({ transition, services }, event) => {
const { data } = event.payload;
const validationResult = services.validation.validateForm(data);
if (validationResult.isValid) {
transition({ type: "VALID", payload: { data } });
} else {
transition({
type: "INVALID",
payload: { errors: validationResult.errors },
});
}
},
SUBMITTING: async ({ transition, services }, event) => {
try {
const { data } = event.payload;
await services.api.saveData(data);
services.storage.saveToLocalStorage("lastSuccessfulSubmit", event.payload.timestamp || Date.now());
transition({ type: "SUBMIT_SUCCESS" });
} catch (error) {
transition({
type: "SUBMIT_ERROR",
payload: { error: error.message },
});
}
},
},
});Управление контекстом
Разделяйте контекст по ответственности
Структурируйте контекст автомата по логическим категориям:
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,
},
},
// ...
});Используйте пользовательские редьюсеры для сложной логики обновления
Держите всю логику по обновлению контекста в редьюсерах, когда это возможно. Редьюсеры идеально подходят для любой логики, которая может быть реализована как чистая функция и не нуждается в доступе к сервисам или состоянию других автоматов.
const todoMachine = createMachine({
config: {
IDLE: {
ADD_TODO: null,
UPDATE_TODO: null,
DELETE_TODO: null,
TOGGLE_TODO: null,
FILTER: null,
},
},
initialContext: {
todos: [],
filter: "all",
stats: {
total: 0,
completed: 0,
active: 0,
},
},
reducer: (state, action, options) => {
// Текущее состояние и контекст
const { state: currentState, context } = state;
const nextState = options.nextState;
// Новый контекст в зависимости от действия
let nextContext;
switch (action.type) {
case "ADD_TODO":
const newTodo = {
id: action.payload.id,
text: action.payload.text,
completed: false,
createdAt: action.payload.createdAt,
};
nextContext = {
...context,
todos: [...context.todos, newTodo],
stats: {
...context.stats,
total: context.stats.total + 1,
active: context.stats.active + 1,
},
};
break;
case "TOGGLE_TODO":
const updatedTodos = context.todos.map((todo) =>
todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo,
);
// Подсчитываем новую статистику
const completedCount = updatedTodos.filter((t) => t.completed).length;
nextContext = {
...context,
todos: updatedTodos,
stats: {
total: updatedTodos.length,
completed: completedCount,
active: updatedTodos.length - completedCount,
},
};
break;
// Другие обработчики...
default:
// Стандартное обновление для необработанных типов
nextContext = {
...context,
...(action.payload || {}),
};
}
return {
state: nextState,
context: nextContext,
};
},
});Примечание: Редьюсеры должны быть чистыми функциями. Вызовы таких методов как
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(), }, }); };
Преимущества использования редьюсеров
- Предсказуемость — редьюсеры обеспечивают предсказуемые изменения состояния, так как они являются чистыми функциями
- Тестируемость — редьюсеры легко тестировать изолированно, просто передавая им состояние и действие
- Централизация логики — вся логика обновления состояния сосредоточена в одном месте, что упрощает отладку и понимание кода
- Разделение ответственности — редьюсеры отвечают за обновление состояния, эффекты — за побочные эффекты, что создает чистую архитектуру
Когда использовать редьюсеры, а когда эффекты
Используйте редьюсеры для:
- Всех преобразований данных и обновлений контекста
- Логики, которая не требует доступа к внешним API или сервисам
- Сложных вычислений на основе имеющихся данных в контексте
- Обновления связанных частей контекста (например, обновление статистики при изменении данных)
Используйте эффекты для:
- Асинхронных операций (API запросы, таймеры)
- Доступа к сервисам (аналитика, локальное хранилище)
- Координации между несколькими автоматами
- Сложной последовательности событий, которые должны происходить на определенных этапах
Используйте вложенные и параллельные автоматы для управления сложностью
При моделировании сложных бизнес-процессов часто возникает проблема “взрыва состояний”, когда количество состояний и переходов становится слишком большим для эффективного управления. В этих случаях стоит использовать концепции из Statecharts:
Параллельные автоматы
Параллельные автоматы позволяют разделить сложную систему на несколько независимых автоматов, которые могут реагировать на одни и те же события одновременно:
// Автомат для управления процессом заказа
const orderMachine = createMachine({
config: {
IDLE: {
START_CHECKOUT: "PROCESSING",
},
PROCESSING: {
PAYMENT_SUCCESS: "COMPLETED",
PAYMENT_ERROR: "ERROR",
},
ERROR: {
RETRY_PAYMENT: "PROCESSING",
CANCEL: "IDLE",
},
COMPLETED: {},
},
// ...
effects: {
PROCESSING: async ({ transition, services }) => {
try {
await services.payment.process();
transition({ type: "PAYMENT_SUCCESS" });
} catch (error) {
transition({ type: "PAYMENT_ERROR", payload: { error } });
}
},
},
});
// Параллельный автомат для управления уведомлениями
// Реагирует на те же события, что и orderMachine
const notificationsMachine = createMachine({
config: {
IDLE: {
// Тот же тип события, что и у orderMachine
START_CHECKOUT: "SHOWING_PROGRESS",
// Обрабатывает те же события успеха/ошибки
PAYMENT_SUCCESS: "SHOWING_SUCCESS",
PAYMENT_ERROR: "SHOWING_ERROR",
},
SHOWING_PROGRESS: {
HIDE: "IDLE",
// Также реагирует на результаты платежа
PAYMENT_SUCCESS: "SHOWING_SUCCESS",
PAYMENT_ERROR: "SHOWING_ERROR",
},
SHOWING_SUCCESS: {
HIDE: "IDLE",
},
SHOWING_ERROR: {
HIDE: "IDLE",
RETRY_PAYMENT: "SHOWING_PROGRESS",
},
},
// ...
effects: {
SHOWING_SUCCESS: ({ services, transition }) => {
// Автоматически скрыть уведомление через 3 секунды
setTimeout(() => transition({ type: "HIDE" }), 3000);
},
},
});
// Параллельный автомат для отслеживания аналитики
// Также реагирует на те же события процесса оформления заказа
const analyticsMachine = createMachine({
config: {
TRACKING: {
// Отслеживает все те же события
START_CHECKOUT: null, // null = остаемся в том же состоянии
PAYMENT_SUCCESS: null,
PAYMENT_ERROR: null,
RETRY_PAYMENT: null,
},
},
// ...
effects: {
TRACKING: ({ action, services }) => {
// Отправляем аналитику при каждом событии
services.analytics.trackEvent(action.type, action.payload);
},
},
});
// Управление через общий менеджер
const manager = MachineManager({
order: orderMachine,
notifications: notificationsMachine,
analytics: analyticsMachine,
});
// Одно событие вызывает параллельную работу всех трех автоматов
manager.transition({ type: "START_CHECKOUT", payload: { orderId: "12345" } });Ключевое преимущество параллельных автоматов: одно и то же событие (например, START_CHECKOUT) обрабатывается одновременно несколькими автоматами, что позволяет разделить ответственность между различными функциональными компонентами системы.
Иерархические (вложенные) автоматы
Вложенные автоматы позволяют моделировать иерархию состояний, где состояние одного автомата влияет на поведение других автоматов:
// Моделирование вложенности через композицию автоматов
const authMachine = createMachine({
config: {
IDLE: {
INIT: "CHECKING_AUTH",
},
CHECKING_AUTH: {
HAS_SESSION: "AUTHENTICATED",
NO_SESSION: "UNAUTHENTICATED",
},
AUTHENTICATED: {
LOGOUT: "UNAUTHENTICATED",
},
UNAUTHENTICATED: {
LOGIN: "LOGIN_PENDING",
},
LOGIN_PENDING: {
LOGIN_SUCCESS: "AUTHENTICATED",
LOGIN_ERROR: "LOGIN_ERROR",
},
LOGIN_ERROR: {
RETRY: "LOGIN_PENDING",
RESET: "UNAUTHENTICATED",
},
},
// ...
});
// Автомат для управления профилем, который напрямую реагирует на события аутентификации
const profileMachine = createMachine({
config: {
INACTIVE: {
// Прямая реакция на те же события аутентификации
HAS_SESSION: "LOADING",
LOGIN_SUCCESS: "LOADING",
},
LOADING: {
LOAD_SUCCESS: "LOADED",
LOAD_ERROR: "ERROR",
},
LOADED: {
UPDATE: "UPDATING",
// Профиль возвращается в неактивное состояние при выходе
LOGOUT: "INACTIVE",
},
UPDATING: {
UPDATE_SUCCESS: "LOADED",
UPDATE_ERROR: "ERROR",
// Также реагируем на выход из системы
LOGOUT: "INACTIVE",
},
ERROR: {
RETRY: "LOADING",
// Также реагируем на выход из системы
LOGOUT: "INACTIVE",
},
},
// ...
effects: {
LOADING: async ({ services, transition }) => {
try {
const profileData = await services.api.fetchProfile();
transition({ type: "LOAD_SUCCESS", payload: { profile: profileData } });
} catch (error) {
transition({ type: "LOAD_ERROR", payload: { error } });
}
},
},
});
// Управление через общий менеджер
const manager = MachineManager({
auth: authMachine,
profile: profileMachine,
});
manager.transition({ type: "INIT" });
// Когда обнаружена сессия, событие активирует оба автомата:
// - auth переходит в AUTHENTICATED
// - profile переходит из INACTIVE в LOADING и начинает загрузку данных
manager.transition({ type: "HAS_SESSION" });В этом примере оба автомата напрямую реагируют на одни и те же события (HAS_SESSION, LOGIN_SUCCESS, LOGOUT), что позволяет создать иерархию без использования эффектов для координации. Профиль пользователя автоматически начинает загружаться, когда обнаружена сессия пользователя, и переходит в неактивное состояние при выходе.
Преимущества использования вложенных и параллельных автоматов
- Уменьшение сложности — разделение системы на логические компоненты делает её проще для понимания
- Повышение повторного использования — независимые автоматы могут быть использованы повторно в других частях приложения
- Улучшенная поддерживаемость — изменения в одном автомате не влияют на работу других
- Улучшенное тестирование — автоматы можно тестировать независимо друг от друга
- Естественное моделирование бизнес-процессов — многие бизнес-процессы сами по себе имеют иерархическую и/или параллельную структуру
Подробную информацию о реализации и использовании параллельных автоматов можно найти в разделе Параллельные автоматы.
Дебаггинг и тестирование
Создайте мок-сервисы для тестирования
Для упрощения тестирования создавайте мок-версии сервисов:
// Реальные сервисы
const realServices = {
api: {
fetchData: async () => {
// Реальная реализация API
},
},
};
// Мок-сервисы для тестирования
const mockServices = {
api: {
fetchData: async () => {
// Имитация успешного ответа
return { data: [{ id: 1, name: "Test" }] };
},
},
};
// Мок-сервисы для тестирования ошибок
const errorMockServices = {
api: {
fetchData: async () => {
throw new Error("Network error");
},
},
};
// В тестах
test("should handle successful data fetch", async () => {
const manager = MachineManager({
data: dataMachine,
});
// Установка мок-сервисов
manager.setDependencies({ services: mockServices });
// Вызываем действие
manager.transition({ type: "FETCH" });
// Ждем завершения асинхронных операций
await vi.waitFor(() => {
const { state } = manager.getState().data;
return state === "SUCCESS";
});
// Проверяем результат
const { state, context } = manager.getState().data;
expect(state).toBe("SUCCESS");
expect(context.data).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. Это обеспечивает полную проверку типов для вашей системы конечных автоматов и предотвращает множество ошибок на этапе компиляции.
Полная типизация предоставляет следующие преимущества:
- Автодополнение состояний и событий в IDE
- Раннее обнаружение ошибок на этапе разработки
- Безопасный рефакторинг с автоматическим отслеживанием всех затронутых мест
- Предотвращение ошибок времени выполнения, связанных с неправильными переходами
- Самодокументирование кода через типы, которые всегда соответствуют реальной реализации
Подробную информацию о работе с TypeScript можно найти в разделе Работа с TypeScript.
Заключение
Следуя этим лучшим практикам, вы сможете создавать более надежные, тестируемые и масштабируемые приложения на основе lite-fsm. Конечные автоматы предоставляют прочную основу для моделирования состояний приложения, а правильные паттерны их использования помогают раскрыть весь потенциал этого подхода.