Переходы состояния: reducer и машина состояний
Оглавление · ADT · Асинхронность
Задача: задать правила изменения состояния и не позволить старому ответу перезаписать результат нового запроса. Нужны суммы типов, чистые функции и асинхронность. Пример учебный: в production Atmanki этого автомата нет.
Состояние и событие — разные значения
Состояние отвечает на вопрос «что сейчас известно?», событие — «что произошло?».
loading — состояние, started — событие начала попытки. Reducer вычисляет
следующее состояние по текущему и событию:
step: State × Event → State.
Это чистая функция: она не читает сеть и часы, не запускает таймер,
не вызывает AbortController. Правило перехода можно проверить без браузера.
В React такая функция может использоваться с useReducer, но сама идея
не зависит от React. Редьюсеры в React.
Если событие недопустимо в текущем состоянии, у модели несколько вариантов: сохранить состояние, вернуть ошибку перехода или бросить исключение. Ниже выбрано сохранение состояния: повторный или устаревший ответ игнорируется. Для финансовой операции молчаливый отказ может быть неподходящей политикой.
Модель одной актуальной попытки
type State =
| { status: "idle"; requestId: number }
| { status: "loading"; requestId: number }
| { status: "success"; requestId: number; titles: readonly string[] }
| { status: "failure"; requestId: number; message: string }
| { status: "cancelled"; requestId: number };
type LoadEvent =
| { type: "started"; requestId: number }
| { type: "succeeded"; requestId: number; titles: readonly string[] }
| { type: "failed"; requestId: number; message: string }
| { type: "cancelled"; requestId: number };
const initialState: State = { status: "idle", requestId: 0 };
requestId — номер попытки, не идентификатор события CMS. Владелец этого
учебного автомата выдаёт возрастающие положительные безопасные целые числа,
не переиспользуя их в течение жизни экземпляра. Это предпосылка примера;
тип number сам по себе её не гарантирует.
После завершения номер сохраняется. Поэтому отменённую или завершённую попытку нельзя снова принять за новую. Автомат хранит одну актуальную попытку, даже если старые операции физически ещё выполняются. Прошлые данные при обновлении здесь не сохраняются; такое поведение нужно моделировать отдельно.
Исходник схемы
stateDiagram-v2 [*] --> Idle Idle --> Loading: started с новым номером Loading --> Loading: started с большим номером Loading --> Success: succeeded актуальной попытки Loading --> Failure: failed актуальной попытки Loading --> Cancelled: cancelled актуальной попытки Success --> Loading: started с большим номером Failure --> Loading: started с большим номером Cancelled --> Loading: started с большим номером
Стрелки задают разрешённые изменения. Проверка номера — условие перехода. Все другие сочетания оставляют состояние без изменения; эти петли опущены, чтобы схема читалась. Тип состояния ограничивает поля, схема — переходы.
Реализация правил
function unreachable(value: never): never {
throw new Error("Unexpected event");
}
function step(state: State, event: LoadEvent): State {
switch (event.type) {
case "started":
return event.requestId > state.requestId
? { status: "loading", requestId: event.requestId }
: state;
case "succeeded":
return state.status === "loading" && event.requestId === state.requestId
? { status: "success", requestId: state.requestId, titles: [...event.titles] }
: state;
case "failed":
return state.status === "loading" && event.requestId === state.requestId
? { status: "failure", requestId: state.requestId, message: event.message }
: state;
case "cancelled":
return state.status === "loading" && event.requestId === state.requestId
? { status: "cancelled", requestId: state.requestId }
: state;
default:
return unreachable(event);
}
}
Начало принимается только с большим номером. Результат принимается только
во время загрузки и только для текущего номера. Массив заголовков копируется,
чтобы последующая мутация массива отправителя не меняла сохранённый результат.
Элементы — строки, поэтому для этого примера достаточно внешней копии.
readonly дополнительно ограничивает работу через тип, но не замораживает массив.
unreachable проверяет полноту разбора вариантов события. Условия внутри
веток по-прежнему требуют проверки поведения: typecheck не доказывает,
что устаревший ответ был отброшен.
Поздний ответ старого запроса
const loadingA = step(initialState, { type: "started", requestId: 1 });
const loadingB = step(loadingA, { type: "started", requestId: 2 });
const afterOld = step(loadingB, {
type: "succeeded",
requestId: 1,
titles: ["Старые данные"],
});
console.log(afterOld === loadingB); // true: старый ответ проигнорирован
const finished = step(afterOld, {
type: "succeeded",
requestId: 2,
titles: ["Новые данные"],
});
console.log(finished); // success, requestId: 2, titles: ["Новые данные"]
Исходник схемы
sequenceDiagram participant Owner as Владелец состояния participant A as Запрос 1 participant B as Запрос 2 Owner->>A: Начать попытку 1 Owner->>B: Начать попытку 2 B-->>Owner: Результат 2: принять A-->>Owner: Результат 1: игнорировать
Схема показывает другой порядок ответов, чем пример кода: в обоих случаях результат попытки 1 не становится актуальным после начала попытки 2. Номер не ускоряет запрос и не останавливает его; он задаёт право обновить локальную модель.
При отмене актуальной попытки состояние становится cancelled. Поздний
succeeded с тем же номером игнорируется, потому что загрузка уже завершена.
Следующая попытка получает новый номер, а не повторно использует старый.
Трассы и инварианты
Трасса — последовательность событий. Последовательное применение step
сворачивает её в итоговое состояние:
const trace: LoadEvent[] = [
{ type: "started", requestId: 1 },
{ type: "cancelled", requestId: 1 },
{ type: "succeeded", requestId: 1, titles: ["Поздний ответ"] },
{ type: "started", requestId: 2 },
{ type: "failed", requestId: 2, message: "Источник недоступен" },
];
const finalState = trace.reduce(step, initialState);
console.log(finalState); // failure, requestId: 2
Для допустимых входных событий можно проверить инварианты:
- Номер текущей попытки не уменьшается.
- Завершающее событие другой попытки сохраняет состояние.
- Повторное завершающее событие не меняет уже завершённую попытку.
- Входное состояние и переданное событие не мутируют.
Индукция по длине трассы связывает локальные правила с поведением серии: начальное состояние удовлетворяет инварианту; каждый переход его сохраняет. Проверка нескольких трасс служит свидетельством реализации, но не заменяет доказательства для всех трасс. Свойства справедливы при оговорённых предпосылках номеров и корректно разобранных событий.
Где выполняется эффект
Владелец сначала принимает действие пользователя и создаёт номер попытки,
применяет started, затем запускает загрузку. После завершения отправляет
succeeded или failed с тем же номером. Отмена включает два действия:
переход cancelled и отдельный вызов abort() у операции.
Reducer описывает изменение модели; исполнитель отвечает за сеть и освобождение
ресурсов. Повторный вызов чистого reducer не отправляет запрос ещё раз.
Если запуск эффекта происходит независимо от принятия started, устаревшая
команда может зря нагрузить источник — проверка состояния сама её не остановит.
В Atmanki CMS-клиент возвращает Promise и ошибку источника, а поиск документации фильтрует локально уже полученные тексты. У поиска нет такого сетевого автомата: не следует приписывать учебную реализацию текущему приложению.
Состояние запроса не является состоянием всех бизнес-данных. При переходе к
React отдельно определим владельцев фильтра, URL, формы и серверного кеша.
Один глобальный reducer для всех этих задач не следует из формулы step.
Практика и самопроверка
Сохраните TS-блоки главы в один временный apps/docs/src/lesson.ts своей учебной
ветки и запустите pnpm --filter @atmanki/docs typecheck. Для исполнения сохраните
их как lesson.mts и запустите node lesson.mts из его каталога.
Удалите временные файлы после упражнения.
Проверьте обе очередности ответов двух попыток, отмену с поздним успехом,
повторный успех и failed в idle. Для игнорируемого события ожидается тот же
объект состояния через ===; для принятого — новое состояние нужного варианта.
Измените массив события после успешного перехода: сохранённые строки не меняются.
Добавьте вариант состояния refreshing, сохраняющий прошлые данные. До кода
нарисуйте переходы и выберите политику ошибки обновления. Объясните, какие ветки
и проверки придётся изменить и почему ещё один boolean не задаёт эту политику.