Persistence

There is no persistence adapter

Deliberately. There is no machine state to persist.

The position is derived from the answers, and the answers are your row. Persist the row — which you were going to do anyway — and you are done. To resume, build the machine with context loaded from it and call start().

const machine = createMachine(onboard, { scripts, context: await load(accountId) })
await machine.start() // exactly where they left off

Writes belong in actions

Name them in the definition and point them at your database through options.scripts; the definition does not change between an in-memory test and production.

createMachine(onboard, {
  scripts: { patchAccount: (ctx, e) => db.account.update(ctx.id, e.patch) },
  context,
})

A script is a function (context, event) — sync or async — or the name of one in options.scripts. The engine awaits the result.

Why a snapshot would be worse

An adapter that snapshotted the machine would be strictly worse:

  • the snapshot can disagree with the row,
  • it goes stale the moment an answer changes,
  • and it pins users to the version of the flow that was live when they started — which is exactly the forward migration you wanted.

options.state

options.state does exist, for genuinely non-derivable positions. It lands on the stored value without running entry actions and without settling, so a resume cannot re-fire an invoke.

Do not reach for it to make a wizard resume; that is free.

// only for a position the answers cannot reproduce
createMachine(flow, { state: { region: 'step' } })

Context is mutated, and merged shallowly

options.context is merged over definition.context, shallowly, and the machine mutates its context.

If definition.context holds nested objects, build it with a factory instead — otherwise every machine compiled from that definition shares them.

export const initialContext = () => ({
  account: {},
  passkey: [],
  emailCount: 0,
})