Hydration и snapshots
Эта страница описывает механику снимков состояния (snapshot): как выгрузить состояние MachineManager, передать его в другое место и применить обратно. Для автоматического хранения в localStorage, IndexedDB, серверной сессии или другом storage используйте пакет @lite-fsm/persist.
Примеры reducer-ов на этой странице написаны в стиле Immer: они мутируют draft
stateи предполагают, чтоMachineManagerсоздан сimmerMiddleware. Без middleware reducer должен вернуть новый объект{ state, context }.
В API за это отвечают два метода: dehydrate() собирает переносимый снимок состояния, а hydrate() применяет такой снимок обратно к менеджеру.
type ManagerSnapshot = {
schemaVersion?: number;
machines: {
[machineKey: string]: unknown;
};
};Термины и определения
- Менеджер — экземпляр
MachineManager, который хранит состояние машин и выполняет переходы. - Снимок состояния (
snapshot) — объект с данными, по которым состояние можно восстановить позже. - Переносимый снимок — результат
dehydrate(). Он предназначен для сохранения, передачи на клиент или импорта в другой экземплярMachineManager. - Текущий снимок — результат
getSnapshot(). Он отражает внутреннее состояние менеджера как есть и не вызывает обработчикиdehydrate. - Обёртка снимка — внешний объект
{ schemaVersion, machines }. Вmachinesлежат снимки отдельных машин по их ключам. - Ключ машины — имя машины в объекте, переданном в
MachineManager, напримерcounterвMachineManager({ counter }). - Доменная машина — обычная машина в
MachineManager, которая хранит состояние приложения или отдельной предметной области. - Шаблон актора — машина с состоянием
__INIT, из которой менеджер создаёт независимые экземпляры акторов. persistence— настройка шаблона актора, которая говорит, должны ли активные акторы попадать в переносимый снимок.- Выгрузка состояния (
dehydrate) — сбор переносимого снимка из текущего состояния менеджера. - Восстановление состояния (
hydrate) — применение снимка к менеджеру. - Обработчики
dehydrateиhydrate— функции в конфигурации машины, которые задают, какие данные попадут в снимок и как эти данные будут применены обратно. - Стратегия восстановления (
strategy) — режим применения входящего снимка. Это не глубокое объединение поляcontextи не глобальная замена всего состояния менеджера. - Промежуточные обработчики (
middleware) — функции, переданные вMachineManagerчерез опциюmiddleware. - Предварительный расчёт (
preview) — расчёт состояния послеhydrate()без изменения менеджера. Его делаетgetHydratedState(). - Временный слой состояния (
overlay) — состояние внутриFSMHydrationBoundary, которое компоненты читают во время отрисовки, пока основной менеджер ещё не изменён. - Машины, участвующие в снимке — доменные машины и шаблоны акторов с
persistence: "snapshot". Обычные акторы сpersistence: "runtime"вdehydrate()не попадают.
Когда использовать
Используйте выгрузку и восстановление, если состояние нужно сохранить, передать или получить извне. Не используйте hydrate() как обычный способ отправлять события: для пользовательских действий должен оставаться transition().
Типичные сценарии:
- сохранить часть состояния перед перезагрузкой страницы;
- восстановить состояние из снимка, полученного с сервера;
- показать часть React-дерева уже с серверным состоянием;
- импортировать заранее подготовленное состояние в тестах или на странице предпросмотра;
- перенести только публичную часть контекста, не сохраняя временные служебные поля.
Для автоматического сохранения снимка в
localStorage,IndexedDBили другое хранилище и синхронизации между вкладками используйте модуль Persist — он построен поверхdehydrate/hydrate.
Методы менеджера
У MachineManager есть четыре метода для работы со снимками состояния:
| Метод | Что делает |
|---|---|
getSnapshot() | Возвращает текущий снимок без обработчиков dehydrate. Включает состояние всех машин и активные записи акторов. |
dehydrate(opts?) | Возвращает переносимый снимок. Вызывает обработчики dehydrate и по умолчанию включает все машины, участвующие в снимке. |
getHydratedState(snapshot, opts?) | Рассчитывает состояние после hydrate() без изменения менеджера, вызова middleware, эффектов и подписчиков. |
hydrate(snapshot, opts?) | Применяет снимок к менеджеру. Не вызывает middleware и не запускает эффекты. |
MachineManager(machines, { snapshot }) применяет начальный снимок при создании менеджера со стратегией replace. Используйте эту опцию, когда состояние нужно восстановить до передачи менеджера в приложение. Это не сбрасывает машины, которых нет в snapshot.machines: для доменных машин применяются только входящие ключи, а для акторов, сохраняемых в снимок, replace считает входящий набор записей полным.
Базовый пример
По умолчанию снимок машины имеет форму { state, context }. Если нужно сохранить только часть данных или поменять формат снимка, добавьте обработчики hydrate и dehydrate в конфигурацию машины.
import { MachineManager, createMachine } from "@lite-fsm/core";
import { immerMiddleware } from "@lite-fsm/middleware";
type CounterEvent = { type: "INC" };
type CounterSnapshot = { count: number };
const counter = createMachine({
config: {
IDLE: {
INC: null,
},
},
initialState: "IDLE",
initialContext: {
count: 0,
lastError: null as string | null,
},
reducer: (state, action, { nextState }) => {
state.state = nextState;
switch (action.type) {
case "INC": {
state.context.count += 1;
break;
}
}
},
dehydrate: (slice): CounterSnapshot => ({
count: slice.context.count,
}),
hydrate: (prev, snapshot: CounterSnapshot) => {
if (prev.context.count === snapshot.count) return prev;
return {
state: prev.state,
context: {
...prev.context,
count: snapshot.count,
},
};
},
});
const manager = MachineManager(
{ counter },
{
schemaVersion: 1,
middleware: [immerMiddleware],
},
);
manager.transition({ type: "INC" });
const snapshot = manager.dehydrate();
// {
// schemaVersion: 1,
// machines: {
// counter: { count: 1 }
// }
// }
const restored = MachineManager(
{ counter },
{
schemaVersion: 1,
snapshot,
middleware: [immerMiddleware],
},
);В обработчике hydrate возвращайте prev, если снимок ничего не меняет. Тогда manager.hydrate() не создаст новое общее состояние, не уведомит подписчиков и не вызовет лишнюю перерисовку в React.
Частичная выгрузка
dehydrate() без аргументов выгружает все машины, которые участвуют в снимке: доменные машины и шаблоны акторов с persistence: "snapshot". Можно явно выбрать только нужные ключи:
const snapshot = manager.dehydrate({
machines: ["counter"],
});Если передать неизвестный ключ машины, dehydrate() бросит ошибку. Это защищает от опечаток в контрактах сохранения.
Стратегии восстановления
hydrate() принимает стратегию:
manager.hydrate(snapshot, { strategy: "merge" });
manager.hydrate(snapshot, { strategy: "replace" });Стратегия отвечает на вопрос: считать входящий снимок частичным наложением или полным набором записей там, где машина содержит несколько записей. Она не выполняет глубокое объединение поля context и не сбрасывает состояние менеджера целиком. В обоих режимах hydrate() рассматривает только ключи из snapshot.machines; машины, которых там нет, остаются без изменений.
merge используется по умолчанию. Доменные машины из снимка применяются через их обработчик hydrate или напрямую, если обработчик не задан. Акторы, сохраняемые в снимок, добавляются или обновляются; живые акторы, которых нет во входящем наборе записей, остаются.
replace тоже применяет только ключи из snapshot.machines, но передаёт meta.strategy === "replace" в обработчики. Для доменных машин без собственного hydrate это не отличается от прямого применения входящего фрагмента состояния; если нужна другая семантика, её задаёт обработчик hydrate. Для акторов, сохраняемых в снимок, replace считает входящий набор записей полным и удаляет активных акторов, которых в нём нет.
hydrate: (prev, snapshot, meta) => {
if (meta.strategy === "replace") {
return {
state: "READY",
context: {
...prev.context,
user: snapshot.user,
},
};
}
return {
state: prev.state,
context: {
...prev.context,
user: snapshot.user,
},
};
},Предварительный расчет без изменения состояния
getHydratedState() нужен, когда нужно заранее посчитать результат hydrate(), но пока не менять сам менеджер. Это важно для React: серверная отрисовка и первый клиентский рендер должны увидеть одно и то же состояние, иначе React может получить ошибку гидрации из-за разной разметки.
const preview = manager.getHydratedState(snapshot);
console.log(preview.counter.context.count);
console.log(manager.getState().counter.context.count); // старое значениеМожно считать следующий предварительный результат поверх уже подготовленного базового состояния:
const firstPreview = manager.getHydratedState(counterSnapshot);
const secondPreview = manager.getHydratedState(flagSnapshot, {
baseState: firstPreview,
});Именно этот механизм использует FSMHydrationBoundary. Он дает компонентам состояние из снимка уже во время отрисовки, но откладывает изменение менеджера до безопасного момента в браузере.
Версия схемы и неизвестные ключи
schemaVersion хранится на уровне обертки снимка:
const manager = MachineManager(
{ counter },
{
schemaVersion: 2,
onSchemaVersionMismatch: (incoming, current) => {
console.warn("Snapshot schema mismatch", { incoming, current });
},
onUnknownMachineKey: (key, context) => {
console.warn("Unknown machine in snapshot", { key, context });
},
},
);При hydrate() несовпадение версии вызывает onSchemaVersionMismatch, а неизвестные ключи машин пропускаются и передаются в onUnknownMachineKey. При getHydratedState() эти обработчики и предупреждения в режиме разработки не вызываются.
Интеграция с React
Для React используйте FSMHydrationBoundary из @lite-fsm/react, когда часть дерева должна сразу отрисоваться с состоянием из снимка. Компоненты внутри FSMHydrationBoundary читают состояние через useSelector() и не добавляют отдельную проверку «сервер или клиент».
FSMHydrationBoundary делает две вещи:
- во время отрисовки строит временное наложение состояния через
getHydratedState(), поэтомуuseSelector()внутриFSMHydrationBoundaryсразу читает снимок; - в
useLayoutEffectприменяетmanager.hydrate(), чтобы основной менеджер получил то же состояние. - если задан
transitionAfterHydrate, после применения снимка отправляет указанные события в менеджер.
import { MachineManager } from "@lite-fsm/core";
import { FSMContextProvider, FSMHydrationBoundary, useSelector } from "@lite-fsm/react";
const manager = MachineManager({ counter });
function Counter() {
const count = useSelector((state) => state.counter.context.count);
return <span>{count}</span>;
}
export function App({ snapshot }) {
return (
<FSMContextProvider machineManager={manager}>
<FSMHydrationBoundary snapshot={snapshot} strategy="merge">
<Counter />
</FSMHydrationBoundary>
</FSMContextProvider>
);
}Важно: во время отрисовки FSMHydrationBoundary задаёт временный источник данных только для useSelector(). Поэтому UI внутри FSMHydrationBoundary должен читать состояние через useSelector(): на сервере и на первой клиентской отрисовке он увидит результат предварительного расчёта. useManager().getState() обращается напрямую к менеджеру и до useLayoutEffect вернёт исходное состояние.
События после гидратации
hydrate() сам по себе не запускает эффекты машин: это импорт состояния, а не бизнес-событие. Если после снимка с сервера нужно запустить клиентскую проверку или синхронизацию, используйте transitionAfterHydrate.
export async function ServerLoad({ children }) {
const subscription = await loadSubscription();
return (
<FSMHydrationBoundary
snapshot={{
machines: {
profile: {
state: "READY",
context: { subscription },
},
},
}}
transitionAfterHydrate={{ type: "CHECK_ONBOARDING" }}
>
{children}
</FSMHydrationBoundary>
);
}transitionAfterHydrate принимает одно событие или массив событий:
<FSMHydrationBoundary
snapshot={snapshot}
transitionAfterHydrate={[{ type: "PROFILE_HYDRATED" }, { type: "CHECK_ONBOARDING" }]}
>
<Page />
</FSMHydrationBoundary>Эти события отправляются только в браузере, после manager.hydrate(snapshot, { strategy }). На серверной отрисовке они не выполняются. Повторное монтирование в React StrictMode не отправляет те же события повторно для того же snapshot.
FSMHydrationBoundary можно вкладывать друг в друга. Вложенный FSMHydrationBoundary считает предварительное состояние поверх наложения родителя:
<FSMHydrationBoundary snapshot={counterSnapshot}>
<FSMHydrationBoundary snapshot={sessionSnapshot}>
<Page />
</FSMHydrationBoundary>
</FSMHydrationBoundary>На практике это решает две задачи:
- серверная разметка и первый клиентский рендер строятся из одного и того же снимка;
- после монтирования менеджер получает это состояние через
hydrate(), поэтому дальнейшая клиентская навигация и обычныеtransition()работают уже с актуальным состоянием.
useHydrateSnapshot
Если временное наложение состояния во время отрисовки не нужно, используйте useHydrateSnapshot():
import { useHydrateSnapshot } from "@lite-fsm/react";
function Hydrator({ snapshot }) {
useHydrateSnapshot(snapshot, { strategy: "merge" });
return null;
}Этот хук применяет снимок в useLayoutEffect. Первая отрисовка дочерних компонентов увидит исходное состояние менеджера. Для серверной отрисовки, React Server Components или Suspense используйте FSMHydrationBoundary; для фонового восстановления или клиентской навигации используйте useHydrateSnapshot().
Акторы и снимки состояния
persistence нужен только шаблонам акторов. Он решает, считать ли активные акторы частью сохраняемого состояния.
Почему не один режим
У акторов есть два режима, потому что акторы отличаются от доменных машин. Доменная машина всегда находится по стабильному ключу, например session или cart, и у нее ровно один фрагмент состояния. Акторы создаются и удаляются динамически: у каждого экземпляра есть actorId, groupId, groupTag, внутренние индексы адресации и возможные ожидающие эффекты.
Если бы все акторы автоматически работали как обычные доменные машины, при hydrate() возникала бы неоднозначность при объединении входящих записей с уже живыми локальными акторами:
- входящий
actorIdможет совпасть с локальным актором, созданным в этом запуске приложения; - локальный актор может быть временным процессом, который нельзя восстанавливать извне;
- внешний снимок может описывать не весь текущий запуск приложения, а только часть объектов;
- при конфликте нельзя надёжно определить источник изменений: клиент, сервер или другой пользователь.
Поэтому режим выбирается явно.
Режимы
По умолчанию используется persistence: "runtime": акторы живут только в текущем запуске приложения, dehydrate() пропускает их, а hydrate() игнорирует записи акторов из входящего снимка. Это защищает от случайного восстановления временных процессов: запросов, таймеров, одноразовых уведомлений или эффектов, которые не должны восстанавливаться после перезагрузки страницы.
Если активные акторы должны сохраняться и восстанавливаться, задайте persistence: "snapshot" в шаблоне актора. Тогда dehydrate() включит их записи в переносимый снимок, а hydrate() сможет восстановить их вместе с actorId, groupId и groupTag.
Практическое правило:
runtime— акторы принадлежат текущему клиенту и текущему запуску приложения;snapshot— акторы описывают состояние, которое задаёт внешняя сторона: сервер, сохранённый документ, другая вкладка или другой пользователь.
Например, в совместной canvas-доске локальный черновик текущей линии можно хранить отдельно в состоянии интерфейса или доменной машине. Штрихи, которые пришли от сервера или других пользователей, храните как акторы с persistence: "snapshot": каждый штрих имеет собственный жизненный цикл, может обновляться независимо и приходит в снимке от другого участника. При merge такие входящие штрихи добавятся или обновятся, не стирая локальный черновик.
const requestActor = createMachine({
config: {
__INIT: {
REQUEST_START: "PENDING",
},
PENDING: {
REQUEST_DONE: "__RESOLVED",
},
},
initialState: "__INIT",
initialContext: {
id: "",
},
persistence: "snapshot",
reducer: (state, action, { nextState }) => {
state.state = nextState;
if (action.type === "REQUEST_START") {
state.context.id = action.payload.id;
}
},
dehydrate: (slice) => ({
id: slice.context.id,
state: slice.state,
}),
hydrate: (_prev, snapshot: { id: string; state: "PENDING" }) => ({
state: snapshot.state,
context: {
id: snapshot.id,
},
}),
});В снимке актора менеджер сам сохраняет и восстанавливает actorId, groupId и groupTag рядом с пользовательскими данными снимка. Жизненный цикл и адресация описаны в разделе Акторы.
Ограничения
- Снимок обязан быть объектом с полем
machines, которое тоже является объектом. strategyне сливает поляcontextавтоматически и не сбрасывает отсутствующие доменные машины.hydrate()проверяет обертку снимка, но не проверяет пользовательские данные каждой доменной машины. Это ответственность обработчикаhydrate.hydrate()не вызывает middleware и не запускает эффекты.- Если
hydrate()изменил состояние, подписчики получают одно служебное событие@@lite-fsm/HYDRATE. - Пользовательский
transition({ type: "@@lite-fsm/HYDRATE" })запрещен.