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

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" }) запрещен.
Last updated on