Onboarding wizard

examples/onboard.js in the repository is one onboarding wizard built from twenty-odd sections. examples/ONBOARD.md is what 1state-mermaid renders it to, and examples/onboard.test.js runs it.

Every export is a self-contained series machine: one state per question, an event that records the answer as a self-transition, and a go guard that leaves once the answer is on the context.

A section

export const accountName = {
  initial: 'name',
  states: {
    name: {
      on: { answer: { target: 'name', actions: 'patchAccount' } },
      go: [{ target: 'done', cond: (ctx) => !!ctx.account.name }],
    },
    done: { type: 'final' },
  },
}

Actions are named, not inline. patchAccount is in-memory in the example and a database write in the application; the definition is the same object either way.

Chaining

export const accountOnboard = {
  initial: 'terms',
  states: {
    terms: {
      on: { agree: { target: 'terms', actions: 'createAccount' } },
      go: [{ target: 'username', cond: (ctx) => !!ctx.account.termsAt }],
    },
    username: { ...accountUsername, onFinal: 'passkey' },
    passkey: { ...accountWebauthnPassKey, onFinal: 'recoveryCodes' },
    recoveryCodes: {
      ...accountRecoveryCodes,
      onFinal: [
        { target: 'securityKey', cond: (ctx) => ctx.mfaRequire },
        { target: 'name' },
      ],
    },
    securityKey: { ...accountWebauthnSecurityKey, onFinal: 'name' },
    name: { ...accountName, onFinal: 'emailAddress' },
    // ...
  },
}

Whether the flow asks for a second factor is mfaRequire, and it lives on the transition into securityKey, not inside it. A section that skipped itself with an internal guard would still be entered, which would put it on the trail as a page Back could land on.

Adding the second factor to the flow re-opens finished accounts, which is how an admin turning mfaRequire on reaches existing members. That is forward migration doing its job.

Presence, not truthiness

export const organizationRetention = {
  initial: 'retention',
  states: {
    retention: {
      on: { answer: { target: 'retention', actions: 'patchOrganization' } },
      go: [{ target: 'done', cond: (ctx) => 'retention' in ctx.organization }],
    },
    done: { type: 'final' },
  },
}

Every option — including “none” and the platform default — is a real choice, so presence decides, not the value. Answers that genuinely cannot be told apart from their default get an At marker instead, and the guard reads that.

A list sub-flow

listFlow(key, { min, max }) builds the three pages every list question needs: “add one?”, the row, and review — looping until the user says no more or the list is full.

export const organizationBillingEmail = listFlow('billingEmail', { min: 1, max: 8 })
export const organizationPromoCode = listFlow('promoCode', { max: 10 })
export const organizationCollaborator = listFlow('collaborator', { max: 128 })

<key>Wanted is how many rows the user has asked to enter; the add page rests until the list catches up. At rest the loop edge is closed, which is what keeps the trail replayable: Back walks the section in a straight line. Inside the loop it can only reach back to the first page, which is the honest answer — a repeated page has no single predecessor.

min > 0 drops the “add one?” page entirely rather than guarding it away, because a skipped page would leave a phantom step on the trail for Back to link to.

The events are a trust boundary

const merge = (target, patch = {}) => {
  for (const key of Object.keys(patch))
    if (key !== '__proto__') target[key] = patch[key]
  return target
}

event.patch is whatever the form posted. Object.assign would apply a __proto__ key through its setter, replacing the target’s prototype instead of adding a property — and since every guard here asks "answer" in config, a request could then satisfy a question it never answered, with Object.keys still showing nothing.

Own keys only, and never that one. Presence guards make this the one place in the flow where input sanitising is not optional.

A terminal state that waits

reportOnly: {
  on: { scan: { target: 'reportOnly', actions: 'patchFqdn' } },
  go: [{ target: 'done', cond: (ctx) => !!ctx.fqdnConfig.setupComplete }],
},

Blocking on purpose: nothing the user clicks satisfies it. The flow waits for an external scan to observe the deployed configuration, and the guard reads what the scan wrote. No scheduler, no polling inside the machine — the next send comes from whatever observed the world.

Two entry points

createMachine(onboard, { scripts, context: initialContext() })     // the wizard
createMachine(accountName, { scripts, context: loadedContext })    // /settings/name

No sub-machine names its successor, so the same definitions drive both. Only the starting context and the script registry differ.

initialContext is a factory, not a literal: the machine mutates it, and the nested objects would otherwise be shared between every machine built from the definition.