@lite-fsm/persist
@lite-fsm/persist сохраняет снимки состояния MachineManager во внешнем хранилище и восстанавливает их при запуске приложения. Модуль использует штатные методы менеджера: dehydrate() формирует снимок, hydrate() применяет его обратно, onTransition() сообщает об изменениях.
Установка
npm install @lite-fsm/persistИспользуйте этот модуль, когда нужно:
- сохранить состояние между перезагрузками страницы;
- синхронизировать состояние между вкладками одного браузера;
- восстанавливать состояние из произвольного хранилища:
localStorage,IndexedDB, серверной сессии, файла и т.п.
Если входящий снимок расходится с текущим состоянием, правила объединения для доменных машин задаются в обработчиках hydrate ваших машин. Опция strategy передаётся в manager.hydrate() и особенно важна для акторов, сохраняемых в снимок: merge сохраняет существующих акторов вне входящего набора записей, а replace удаляет отсутствующих.
Импорт
@lite-fsm/persist вынесен в отдельную точку входа, поэтому основной пакет не загружает код сохранения состояния без явного импорта. React-хуки доступны из @lite-fsm/persist/react: @lite-fsm/react не зависит от этого модуля и не включает его в клиентский пакет, пока хуки не используются.
import { persistManager, createJsonStorage } from "@lite-fsm/persist";
import type { PersistController, PersistStorage, PersistStatus } from "@lite-fsm/persist";
import { usePersistStatuses, useIsPersistRestoring } from "@lite-fsm/persist/react";Базовый пример
Минимальная схема подключения: создать менеджер, передать его в persistManager, а полученный контроллер передать массивом в свойство persist у FSMContextProvider. Провайдер запустит контроллер при монтировании компонента и остановит его при размонтировании.
import { MachineManager } from "@lite-fsm/core";
import { immerMiddleware } from "@lite-fsm/middleware/immer";
import { createJsonStorage, persistManager } from "@lite-fsm/persist";
import { FSMContextProvider } from "@lite-fsm/react";
import { counter } from "./machines/counter";
const machines = { counter };
type Store = typeof machines;
const manager = MachineManager<Store>(machines, {
middleware: [immerMiddleware],
schemaVersion: 1,
});
const persist = persistManager(manager, {
storage: createJsonStorage<Store>({
key: "app:state:v1",
storage: () => window.localStorage,
}),
storageVersion: 1,
machines: ["counter"],
throttleMs: 500,
onError: console.error,
});
export function App() {
return (
<FSMContextProvider machineManager={manager} persist={[persist]}>
<Counter />
</FSMContextProvider>
);
}persistManager возвращает контроллер, но не начинает работу сам. Подписки и чтение хранилища запускаются только после вызова start() — вручную или через FSMContextProvider.
Контроллер persistManager
Методы
| Метод | Что делает |
|---|---|
start() | Запускает работу контроллера: подписывается на изменения менеджера и хранилища, читает сохранённую запись. Возвращает функцию остановки. |
restore() | Читает запись и применяет её к менеджеру через manager.hydrate(). Возвращает итоговый PersistStatus. |
save() | Немедленно записывает текущий снимок (manager.dehydrate({ machines })) в хранилище. |
flush() | Если сохранение отложено из-за throttleMs, выполняет его немедленно. Если запись уже выполняется, дожидается её завершения. |
clear() | Отменяет отложенное сохранение, удаляет запись из хранилища и сбрасывает статус в { phase: "ready", restored: false }. |
getStatus() | Возвращает текущий PersistStatus. |
subscribeStatus(cb) | Подписывает обработчик на изменения статуса. Возвращает функцию отписки. |
start() можно вызывать несколько раз. Контроллер ведёт счётчик активных запусков и фактически останавливается только после последнего stop(). Это безопасно для React StrictMode и сценариев, где один контроллер подключён к нескольким провайдерам.
При прямом вызове restore, save, flush и clear ошибки пробрасываются вызывающему коду — их можно обработать через try/catch. Фоновые операции, запущенные внутри start(), не пробрасывают ошибки вызывающему коду, чтобы не создавать необработанные Promise. Ошибка всё равно передаётся в onError, а контроллер переходит в фазу "error".
PersistStatus
Текущая стадия работы контроллера. Используйте для индикаторов состояния и журналирования.
type PersistStatus =
| { phase: "idle" }
| { phase: "restoring" }
| { phase: "ready"; restored: boolean }
| { phase: "error"; error: unknown };| Фаза | Когда устанавливается |
|---|---|
"idle" | До первого start() и после последнего stop(). |
"restoring" | Выполняется восстановление: чтение из хранилища, миграция, применение manager.hydrate(). |
"ready" | Восстановление завершилось. restored: true — снимок применён к менеджеру; false — записи в хранилище не было. |
"error" | Ошибка в restore, save или clear. После следующего успешного восстановления статус снова станет "ready". |
Опции persistManager
| Опция | Назначение |
|---|---|
storage | Адаптер хранилища { get, set, remove, subscribe? }. Может быть синхронным или асинхронным. |
machines? | Список машин, которые включаются в снимок. Тот же набор ключей, что и у manager.dehydrate({ machines }). |
strategy? | Режим hydrate: частичное наложение ("merge") или полный набор записей акторов ("replace"). По умолчанию "merge". |
storageVersion? | Версия записи в хранилище. Если версия не совпадает с сохранённой и migrate не задан, запись удаляется. |
maxAge? | Срок жизни записи в миллисекундах. После истечения запись удаляется и не применяется. |
throttleMs? | Минимальный интервал между записями в хранилище. По умолчанию 0 — запись выполняется без задержки. Для браузерного хранилища начните с 300–1000. |
shouldSave? | Фильтр переходов перед сохранением. Возвращает true, если переход должен попасть в хранилище. |
migrate? | Преобразование старой записи в текущий формат снимка. Возврат undefined приводит к удалению записи. |
onRestoreSettled? | Обработчик результата восстановления. Вызывается при переходе в фазу "ready" или "error". clear() его не вызывает. |
onError? | Обработчик ошибок: (err, "restore" | "save" | "clear") => void. Подключайте к журналированию или системе мониторинга. |
Что попадает в хранилище: machines и shouldSave
Сохранение ограничивается двумя независимыми настройками.
machines определяет, что именно попадает в снимок. Это те же ключи, что и у manager.dehydrate({ machines }). Используйте эту настройку, чтобы исключить из хранилища состояние, которое не должно сохраняться после перезагрузки: черновики форм, временное состояние интерфейса, временные машины запросов.
shouldSave определяет, когда именно записывать снимок, и проверяется отдельно для каждого перехода. Этот фильтр применяется после machines. Например, в чате имеет смысл сохранять только подтверждённые сообщения и очистку истории, но не каждое нажатие клавиши:
const persist = persistManager(manager, {
storage,
storageVersion: 1,
machines: ["chatThread"],
throttleMs: 250,
shouldSave: ({ action }) => action.type === "MESSAGE_SENT" || action.type === "HISTORY_CLEARED",
});Служебный переход @@lite-fsm/HYDRATE, который менеджер генерирует при hydrate(), в shouldSave не передаётся и сохранение не запускает. Поэтому при синхронизации между вкладками не возникает повторных записей в хранилище.
throttleMs: объединение нескольких сохранений в одно
throttleMs задаёт минимальный интервал между записями в хранилище. Когда несколько переходов происходят подряд, контроллер не записывает каждый переход отдельно: он откладывает сохранение на throttleMs миллисекунд и затем сохраняет только итоговое состояние.
Это полезно в нескольких случаях:
- браузерный
localStorage.setItemсинхронный и блокирует основной поток — частые вызовы заметно замедляют интерфейс; - у
IndexedDBи сетевых запросов каждое обращение имеет свою стоимость, поэтому промежуточные состояния обычно не нужно записывать; - при вводе текста или быстром перетаскивании контроллер сохранит только итоговое состояние.
throttleMs: 0 (по умолчанию) означает запись без задержки. Для localStorage обычно используют интервал 300–1000. flush() принудительно выполняет отложенную запись, например перед событием unload.
Хранилище
PersistStorage<S> — минимальный интерфейс, который должен реализовать любой адаптер хранилища. Контроллер не занимается сериализацией и передачей данных: он только читает, пишет и удаляет записи.
type PersistStorage<S extends MachineStore> = {
get(): MaybePromise<PersistedRecord<S> | undefined>;
set(record: PersistedRecord<S>): MaybePromise<void>;
remove(): MaybePromise<void>;
subscribe?(cb: () => void): () => void;
};Запись PersistedRecord<S> содержит MachineManagerSnapshot<S> и два поля метаданных:
type PersistedRecord<S extends MachineStore> = {
timestamp: number;
storageVersion?: string | number;
snapshot: MachineManagerSnapshot<S>;
};timestamp используется для проверки maxAge, storageVersion — для миграций.
createJsonStorage
Готовый адаптер для синхронных хранилищ с интерфейсом localStorage ({ getItem, setItem, removeItem }). Он сериализует запись в JSON через JSON.stringify и читает её обратно через JSON.parse.
import { createJsonStorage } from "@lite-fsm/persist";
const storage = createJsonStorage<Store>({
key: "app:state:v1",
storage: () => window.localStorage,
});storage передаётся как функция. createJsonStorage не вызывает её при создании адаптера: функция вызывается заново на каждом get, set и remove. Результат не кэшируется, среда выполнения не проверяется. Поэтому storage: () => window.localStorage можно объявлять в коде, который участвует в серверном рендеринге: во время рендеринга функция не вызывается. Если явно вызвать get, set или remove там, где window недоступен, ошибка функции хранилища попадёт в обычный путь ошибок хранилища.
createJsonStorage не реализует subscribe — у localStorage нет собственной подписки. Если нужно реагировать на изменения из других вкладок, дополните адаптер вручную (см. ниже).
Синхронизация между вкладками
Браузерное событие storage срабатывает в других вкладках при изменении localStorage. Реализуйте в адаптере подписку на это событие и вызывайте переданный обработчик — контроллер сам запустит фоновое восстановление.
const STORAGE_KEY = "app:state:v1";
const adapter: PersistStorage<Store> = {
...createJsonStorage<Store>({ key: STORAGE_KEY, storage: () => window.localStorage }),
subscribe: (cb) => {
const handle = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage) return;
if (event.key !== null && event.key !== STORAGE_KEY) return;
cb();
};
window.addEventListener("storage", handle);
return () => window.removeEventListener("storage", handle);
},
};Если восстановление начато через subscribe, контроллер не записывает данные обратно в хранилище, чтобы другие вкладки не получили повторное уведомление. Запись произойдёт только если локальное состояние изменилось во время этого восстановления. Поэтому синхронизация между вкладками не уходит в цикл повторных записей.
Пользовательские адаптеры
Тот же контракт подходит для IndexedDB, BroadcastChannel, серверных сессий, файлового хранилища или любого другого транспорта. Требования к адаптеру:
- из
getвозвращатьPersistedRecord<S>илиundefined, если записи нет; - корректно обрабатывать повторные вызовы
setиremove; - если есть внешние изменения — реализовать
subscribe.
Простой адаптер в памяти удобен для тестов:
const memoryAdapter = (): PersistStorage<Store> => {
let record: PersistedRecord<Store> | undefined;
const listeners = new Set<() => void>();
return {
get: () => record,
set: (next) => {
record = next;
for (const cb of listeners) cb();
},
remove: () => {
record = undefined;
for (const cb of listeners) cb();
},
subscribe: (cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
};
};React-интеграция
Свойство persist у FSMContextProvider
FSMContextProvider принимает свойство persist, которое управляет массивом элементов жизненного цикла: при монтировании провайдер вызывает start() у каждого элемента, при размонтировании — возвращённые функции остановки. Серверный рендеринг и гидратация React при этом не блокируются: первое восстановление выполняется в фоне после монтирования.
import { FSMContextProvider } from "@lite-fsm/react";
<FSMContextProvider machineManager={manager} persist={[persist]}>
<App />
</FSMContextProvider>;persist принимает только массив объектов жизненного цикла. Минимальный контракт для провайдера — { start(): () => void }; PersistController уже реализует этот контракт. Массив полезен, когда нужно подключить несколько хранилищ, например localStorage и серверное хранилище:
<FSMContextProvider machineManager={manager} persist={[localPersist, remotePersist]}>
<App />
</FSMContextProvider>Провайдер не запускает persist во время серверного рендеринга: start() вызывается только после монтирования. Если persist не передан или равен [], usePersistStatuses() возвращает пустой массив и на сервере, и на клиенте.
Контекст статуса повторяет порядок массива persist. Элемент с getStatus() и subscribeStatus() возвращает текущий PersistStatus; обычный объект жизненного цикла без этих методов возвращается в массиве как null.
usePersistStatuses, useIsPersistRestoring
Эти хуки подписываются на изменение статусов контроллеров из ближайшего FSMContextProvider. Вне провайдера они бросают ошибку провайдера. Используйте их для индикатора синхронизации, ошибки или скрытия интерфейса на время восстановления.
import { useIsPersistRestoring, usePersistStatuses } from "@lite-fsm/persist/react";
function PersistBadge() {
const [status] = usePersistStatuses();
const restoring = useIsPersistRestoring();
if (restoring) return <span>Загружаем сохранённые данные…</span>;
if (status?.phase === "error") return <span>Не удалось прочитать запись</span>;
if (status?.phase === "ready" && status.restored) return <span>Синхронизировано</span>;
return <span>Готово</span>;
}usePersistStatuses() всегда возвращает массив той же длины и в том же порядке, что persist у ближайшего провайдера:
function PersistBadge() {
const [localStatus, remoteStatus] = usePersistStatuses();
return <span>{localStatus?.phase ?? remoteStatus?.phase ?? "none"}</span>;
}useIsPersistRestoring возвращает true, если хотя бы один статус, отличный от null, имеет phase === "restoring". Если интерфейс должен скрывать данные до завершения первого восстановления, считайте загрузкой обе фазы:
const [status] = usePersistStatuses();
const loading = status?.phase === "idle" || status?.phase === "restoring";Версии и миграции
В библиотеке используются две независимые версии с разными областями применения:
| Поле | За что отвечает |
|---|---|
MachineManager(machines, { schemaVersion }) | Версия модели состояния (MachineManagerSnapshot). Несовпадение вызывает onSchemaVersionMismatch менеджера. |
persistManager({ storageVersion }) | Версия записи в хранилище (PersistedRecord). Несовпадение приводит к удалению записи или вызову migrate. |
Как выбирать версию:
- изменилась структура состояния машин — поднимите
schemaVersion; - изменился формат хранилища (ключи, метаданные, способ сериализации) — поднимите
storageVersion.
const persist = persistManager(manager, {
storage,
storageVersion: 2,
migrate: (record) => {
if (record.storageVersion === 1) return upgradeFromV1(record.snapshot);
return undefined;
},
});Поведение при несовпадении версии:
- без
migrate— запись удаляется, статус становится{ phase: "ready", restored: false }; - с успешным
migrate— возвращённый снимок применяется, контроллер сразу перезаписывает запись с актуальнымstorageVersion; migrateвернулundefined— запись считается несовместимой и удаляется.
maxAge задаёт срок жизни записи в миллисекундах. Просроченные записи удаляются ещё до того, как может вызваться migrate.
Жизненный цикл
start() синхронно возвращает функцию остановки, а чтение из хранилища и установка подписок выполняются в фоне. Поэтому он не задерживает отрисовку и его безопасно вызывать прямо в useEffect.
Поведение контроллера между start() и stop():
- если пользовательский переход произошёл во время восстановления, контроллер не записывает его в хранилище сразу, а фиксирует наличие несохранённых изменений. После завершения восстановления он сохранит итоговое состояние;
restore,save,flush,clearдоступны для ручного вызова в любой момент;clear()отменяет отложенное сохранение, удаляет запись и помечает уже запущенные операции как устаревшие — даже если запущенный ранееsaveилиrestoreзавершится послеclear(), он уже не перезапишет хранилище.
const stop = persist.start();
await persist.flush();
await persist.clear();
stop();Практический пример: чат с синхронизацией между вкладками
В демонстрационном примере persist история чата сохраняется в localStorage, а другие вкладки получают её через событие storage. В хранилище попадает только машина chatThread: машина ввода (chatComposer) и сессия (chatSession) используются только в текущей вкладке.
const machines = { chatThread, chatComposer, chatSession };
type Store = typeof machines;
const manager = MachineManager<Store>(machines, {
middleware: [immerMiddleware],
schemaVersion: 1,
});
const persist = persistManager(manager, {
storage: createLocalStorageAdapter(),
storageVersion: 1,
machines: ["chatThread"],
throttleMs: 250,
shouldSave: ({ action }) => action.type === "MESSAGE_SENT" || action.type === "HISTORY_CLEARED",
onError: console.error,
});import { FSMContextProvider } from "@lite-fsm/react";
import { useIsPersistRestoring, usePersistStatuses } from "@lite-fsm/persist/react";
function ChatApp() {
const [status] = usePersistStatuses();
const restoring = useIsPersistRestoring();
return (
<section>
{restoring && <p>Подключаем хранилище…</p>}
<ChatPanel />
<ChatComposer />
{status?.phase === "error" && <p>Ошибка хранилища</p>}
</section>
);
}
export function App() {
return (
<FSMContextProvider machineManager={manager} persist={[persist]}>
<ChatApp />
</FSMContextProvider>
);
}Откройте демо в нескольких вкладках одновременно: каждая вкладка представляет отдельного пользователя, но история сообщений остаётся общей. Если очистить её в одной вкладке, остальные получат пустой chatThread.
См. также страницу Сохранение состояния, где описана базовая модель dehydrate/hydrate, поверх которой работает persist.
Обработка ошибок
onRestoreSettled и onError решают разные задачи:
onErrorвызывается на каждую ошибку и сообщает её фазу (restore,save,clear). Подключайте к системе журналирования или мониторинга;onRestoreSettledвызывается ровно один раз на каждый цикл восстановления — успешный или нет. Используйте для аналитики и метрик.
const persist = persistManager(manager, {
storage,
storageVersion: 1,
onRestoreSettled: (result) => {
if (result.phase === "ready") {
analytics.track("persist:ready", { restored: result.restored });
return;
}
analytics.track("persist:restore_error", { error: String(result.error) });
},
onError: (err, phase) => {
console.error(`[persist] ${phase}`, err);
},
});| Источник ошибки | Что вызывается |
|---|---|
| Невалидная запись | onError(err, "restore"), запись удаляется, восстановление завершается { phase: "ready", restored: false }. |
Ошибка чтения, миграции или hydrate | onError(err, "restore"), статус { phase: "error" }, onRestoreSettled({ phase: "error", error }). |
save | onError(err, "save"), статус { phase: "error" }. |
clear | onError(err, "clear"). При прямом вызове ошибка пробрасывается дальше. |
Если ваш onError или onRestoreSettled сам сгенерирует исключение, контроллер его подавит — работа persist продолжится.
Ограничения
- Сериализация и совместимость пользовательских данных в записи остаются ответственностью адаптера.
- Служебный переход
@@lite-fsm/HYDRATEне запускает сохранение. manager.hydrate()не проходит через промежуточные обработчики (middleware) и не запускает эффекты — это поведение самого менеджера, иpersistего не меняет.- Запросы к IndexedDB, серверу, файлам и другим асинхронным хранилищам реализуются в пользовательском адаптере; контроллер только вызывает
get,set,removeи реагирует наsubscribe.
См. также:
- Сохранение состояния — базовая модель
dehydrate/hydrateиFSMHydrationBoundary. - Middleware — как преобразовать или дополнить переходы до проверки
shouldSave. - Persist API — точки входа
@lite-fsm/persistи@lite-fsm/persist/react.