Derived position

Nothing anywhere records “the user is on step 3”. The position is a pure function of the answers, recomputed from the initial state on every start(). Guards read the data.

const resumed = createMachine(signup, { context: { email: 'a@example.org' } })
await resumed.start() // value: "password"

No cursor was loaded. start() entered email, found its guard open, moved to password, found that guard closed, and stopped.

What it buys

That one property buys three things that are otherwise separate features:

  • Resume. Load the row into context, start, land on the first gap.
  • No skip-ahead. A later state is unreachable until its predecessors’ guards pass. There is no cursor to forge, because there is no cursor.
  • Forward migration. Add a state and every already-”complete” record re-opens at it. Turning on a policy that inserts a step is how you reach users who finished last year.

None of the three is a feature of the engine. They are consequences of settling from the answers, and they stop being true the moment something stores a position.

How settling works

Two rules do all of it.

on records an answer as a self-transition. The transition targets the state it fires in, so the state exits and re-enters, and its guards run again against the answer just written.

go is eventless. After every step the machine retries the go transitions of whatever is active and keeps going until nothing opens. Where it stops is where the user is.

So a start() on a half-filled context walks the flow from the top, opening guard after guard, until it reaches the first question whose answer is missing. That walk is the resume.

Guards must read the answer

The corollary is that guards must read the answer, not a marker of progress:

go: [{ target: 'done', cond: (ctx) => 'newsletter' in ctx }] // yes
go: [{ target: 'done', cond: (ctx) => ctx.newsletter }]      // no — "no" reads as unanswered

Test for presence (in, === false), not truthiness. false, 0 and "" are answers. A truthiness guard on a boolean question traps every user who answers “no”: the guard stays closed, the flow never leaves, and the page re-asks a question that was already answered.

Where an answer genuinely cannot be told apart from its default, write a separate answeredAt marker and guard on that.

retention: {
  on: { answer: { target: 'retention', actions: 'patchOrganization' } },
  // every option, including the platform default, is a real choice
  go: [{ target: 'done', cond: (ctx) => 'retention' in ctx.organization }],
},

Never restore from the URL

Settling from context is the entire no-skip-ahead guarantee. Feeding toValue(segments) into options.state restores a position the user typed, which walks straight past every guard in front of it. See URLs for the supported way to serve a step page.