Rxova
Skip to content

Persistence

The persistence plugin writes a serializable state slice whenever transitions settle, context changes, or status changes. saveOn narrows which of those write, and debounceMs collapses a burst of them into a single write.

import { createPersistencePlugin } from "@rxova/journey-core/plugins";
const machine = createLinearJourney(definition, {
plugins: [
createPersistencePlugin({
storage: localStorage,
key: "checkout",
clearOnTerminate: true
})
]
});

storage must implement getItem, setItem, and removeItem. setItem may return a promise.

createPersistencePlugin({
storage: localStorage,
key: "checkout-draft",
debounceMs: 300,
saveOn: ["context", "transition"]
});

Omit debounceMs (or pass 0) and each observation writes immediately. Pass a window and the plugin waits that long after the last observation, so typing into a field produces one write rather than one per keystroke. flushPersisted() cancels the wait and writes now.

This used to be a second plugin called autosave. It was this plugin with a timer — the same serializer, the same adapter contract, the same key — so it is a parameter now.

An immediate write that throws propagates, exactly as it always did, and the runtime’s listener isolation reports it. A debounced write happens on a timer with no caller left to throw to, so its failures land in getPersistenceState().error and are reported through onListenerError.

Disposing the machine cancels a pending debounce. It does not flush automatically.

For the common case, every factory accepts persist as sugar over the plugin:

const machine = createLinearJourney(definition, {
persist: { key: "checkout" }
});

persist expands into the persistence plugin, prepended to plugins. storage is optional here and defaults to globalThis.localStorage; creation throws when neither a storage value nor localStorage is available. Combining persist with an explicitly registered persistence plugin fails at creation as a duplicate plugin name. Use the explicit plugin form when you need clearOnTerminate or an injected clock.

Unlike the explicit plugin, persist also restores: a valid record found at creation seeds the machine so the first start() resumes where the record left off.

type JourneyPersistedState = {
status: JourneyStatus;
context: unknown;
timeline: readonly string[];
currentIndex: number;
savedAt: number;
};

The plugin serializes this value with JSON.stringify. Keep persisted context serializable.

const api = machine.plugins.persistence;
api.inspectPersistedState(); // last value written by this machine
api.readPersisted(); // re-read and parse storage
api.clearPersisted(); // cancel any pending write and remove the entry
await api.flushPersisted(); // cancel the debounce and write now
machine.getSnapshot().plugins.persistence;
// {
// status: "idle" | "pending" | "saving" | "saved" | "error",
// lastSavedAt: number | null,
// error: unknown | null
// }

Malformed or structurally invalid storage values return null. status only passes through "pending" when debounceMs is set — an immediate write has no window in which to be pending. Use the namespaced snapshot value in selectors when the UI displays save status.

The creation-time persist option restores. At creation, the factory reads the stored record; when it is restorable, the record seeds context, timeline, and pointer, and the first start() re-enters the persisted current step instead of the first/initial one:

const machine = createLinearJourney(definition, {
persist: { key: "checkout" }
});
// creation starts the machine, resuming at the persisted step when a valid record existed

A record is restorable when its status is running or paused, its currentIndex points inside its timeline, and every timeline step is declared by the current definition. Anything else — terminal-status records, definition drift, malformed or foreign payloads, a throwing storage read — is ignored and the journey starts fresh. Restore is best-effort by design and never throws.

Details of a restored start:

  • the initial entry runs as a normal stepEnter with from: null and direction: "jump";
  • visit counts are reconstructed from the restored timeline, so the re-entered step reports isFirstTimeVisit: false;
  • an explicit startAt option wins over the persisted record;
  • restart() always begins a fresh run — the seed applies only to the first start().

Registering createPersistencePlugin explicitly in plugins stays save-only: plugins are observe-only and cannot seed the runtime. Use the persist option when you want restore.

OptionMeaning
storageRequired localStorage-compatible adapter.
keyRequired storage key.
debounceMsWait this long before writing; omitted or 0 writes immediately.
saveOnAny of context, transition, status; defaults to all three.
clearOnTerminateRemove the entry on termination; defaults to false.
nowInjectable clock, mainly for tests.