Stately
XState v6 alpha

Persistence

Save and restore actor state.

XState v6 is in alpha

APIs and behavior may change before the stable release.

Call getPersistedSnapshot() to get serializable actor state.

const persisted = actor.getPersistedSnapshot();
localStorage.setItem('checkout', JSON.stringify(persisted));

Restore the actor with the snapshot option.

const restored = createActor(machine, {
  snapshot: JSON.parse(localStorage.getItem('checkout')!)
}).start();

Persisted snapshots may include child actor state. Keep the actor logic compatible with snapshots already stored by your application.

Persistence is useful for a checkout resumed after a refresh and a backend order workflow resumed by a later request.

Store a machine version with each snapshot. Migrate or discard old snapshots when states, context or child actor logic change. Keep database clients, sockets and other live resources outside context.

Persisted snapshots record current state. Event sourcing records the events that produced it. Use event sourcing only when the event history itself is required.

Migrate machine versions

Register lightweight schema descriptors for historical versions and retain an actual machine for each version that may be a target. Every entry must have the same stable id and its own version.

Every entry satisfies one MachineVersionDescriptor contract: { id, version, snapshotSchema?, eventSchema? }. Versioned machines expose snapshotSchema and eventSchema themselves, so machineVersions() uses the same schema path for machines and lightweight historical descriptors. It only checks whether an entry is executable when resolving to.

const checkoutVersions = machineVersions([
  {
    id: 'checkout',
    version: '1',
    // This version had no persisted children, history, timers or state inputs.
    snapshotSchema: z.object({
      status: z.enum(['active', 'done', 'error', 'stopped']),
      output: z.unknown().optional(),
      error: z.unknown().optional(),
      value: z.literal('active'),
      context: z.object({ count: z.number() }),
      children: z.object({}),
      historyValue: z.object({}),
      timers: z.object({}),
      _nextActorId: z.number().optional(),
      _nextTimerId: z.number(),
      stateInputs: z.undefined().optional(),
      machine: z.object({
        id: z.literal('checkout'),
        version: z.literal('1')
      }),
      version: z.literal('1')
    }),
    eventSchema: z.discriminatedUnion('type', [
      z.object({ type: z.literal('ADD'), value: z.number() }),
      z.object({ type: z.literal('REMOVE'), value: z.number() })
    ])
  },
  checkoutV2
]);
const snapshot = await checkoutVersions.migrateSnapshot(
  JSON.parse(localStorage.getItem('checkout')!),
  {
    to: '2',
    migrations: {
      '1': async (snapshot) => ({
        ...snapshot,
        context: { total: snapshot.context.count }
      })
    }
  }
);

const actor = createActor(checkoutV2, { snapshot }).start();

Exact version keys narrow the callback to that historical schema's output type. Migration is direct to the target machine and may be asynchronous. You do not need to retain old executable machines or define every intermediate version.

A snapshotSchema describes the entire persisted contract, not only context: status/output/error, state value, context, children, history, timers, state inputs, counters and version metadata as applicable. Active state nodes are reconstructed from the state value, so nodes itself is not persisted. A versioned machine's generated snapshotSchema validates this durable shape, its state value and its configured context schema. It normalizes omitted historyValue and timers to empty records, matching restoration behavior.

Use '*' to handle any snapshot that cannot use an exact retained version. The snapshot is unknown, so the migration can inspect its shape or load an old schema only when needed:

const checkoutVersions = machineVersions([checkoutV2]);
const snapshot = await checkoutVersions.migrateSnapshot(persisted, {
  to: '2',
  migrations: {
    '*': async (snapshot, source) => {
      const { checkoutV1Snapshot, migrateV1 } = await import(
        './checkout-v1-migration'
      );
      return migrateV1(await checkoutV1Snapshot.parseAsync(snapshot));
    }
  }
});

An exact version migration runs before '*'. If neither matches, migration throws. Standard Schema validation may itself be asynchronous. There is no separate lazy-loader API: use an async Standard Schema or the existing '*' route when schema code must be imported conditionally. The target machine's snapshot schema validates the stamped result, so a migration must produce a valid status, a state value the target machine can resolve, and children.

The to version must be backed by an actual machine, because it interprets the restored state. machine.version remains the compatibility stamp written to the nested machine identity and legacy top-level version field.

When adopting versioning for snapshots that were already persisted without a version, describe the old snapshot contract as an explicit version and configure it as unversioned:

const checkoutVersions = machineVersions([checkoutV0, checkoutV1], {
  unversioned: '0'
});

This policy applies only when version metadata is absent. parseSnapshot() still rejects an explicit unknown version; migrateSnapshot() may handle it with '*'.

Use a runtime Standard Schema such as Zod to reject invalid persisted data. types<T>() provides TypeScript inference only and does not validate at runtime.

Adapt event histories

Event adaptation is separate from snapshot migration. Pass the source identity once for the stream; events do not need version envelopes or metadata.

const events = await checkoutVersions.adaptEvents(storedEvents, {
  from: { id: 'checkout', version: '1' },
  to: '2',
  adapters: {
    '1': async (events) => [
      {
        type: 'totalChanged',
        amount: events.reduce(
          (total, event) => total + event.amount,
          0
        )
      }
    ]
  }
});

Adapters receive and return whole arrays, so they may insert, drop, combine or reorder events. Exact retained-version adapters receive typed source events and must return target events. '*' receives unknown[] plus the caller-provided source identity, allowing lazy schema imports or shape-based adaptation.

An exact adapter takes precedence over '*'. A matching target version needs no adapter. Source histories are validated before exact adapters when a source event schema is available; unrecognized histories may fall through to '*'. Every result, including a same-version history, is validated against available target event schemas. If no applicable adapter exists, adaptation throws. An exact adapter's error propagates instead of falling through to '*'.

An eventSchema validates each complete historical event object and infers the exact adapter's event union. A descriptor may provide snapshotSchema, eventSchema or both. If the relevant schema is absent, that operation may use its unknown '*' handler instead. Actual machines continue to work directly as entries. Their generated eventSchema turns the payload-oriented schemas.events map into a Standard Schema for complete event objects. Without that schema or a '*' adapter, adaptation reports the missing event schema rather than treating the registered version as unknown.

Exact event and snapshot targets require actual machines. A schema descriptor describes historical data but cannot interpret restored state or receive events.

adaptEvents() only adapts a materialized history. It does not store or replay events and does not produce a snapshot.

Persistence cheatsheet

const persisted = actor.getPersistedSnapshot();
const restored = createActor(logic, { snapshot: persisted }).start();

On this page