A wizard whose next step depends on earlier answers — business accounts get a company step, only non-US businesses get a tax step — turns into nested if statements scattered across “Next” handlers, and the back button eventually lands somewhere the forward path never visited.

Multi-step form state machines argues for deriving the path from answers. XState makes that argument executable: the steps are states, “Next” and “Back” are events, branching is a guard on a transition, and the machine refuses events that are not valid in the current state. This page builds one with XState v5 and shows the parts that matter for forms specifically.


Context and prerequisites

The example wizard has five possible steps:

  1. account — personal or business.
  2. company — only for business accounts.
  3. address — everyone.
  4. tax — only for business accounts outside the US.
  5. review — everyone, then submit.

A personal account visits account → address → review. A US business visits account → company → address → review. A non-US business visits all five. “Back” from review must go to whichever step preceded it on this user’s path, and if the user goes back to account and switches to personal, the company and tax answers must stop counting without being thrown away.

The branching path as states and guarded transitions From account, NEXT goes to company if the account is business, otherwise straight to address. From company, NEXT goes to address. From address, NEXT goes to tax if the account is business and the country is not US, otherwise to review. From tax, NEXT goes to review. From review, SUBMIT enters submitting, which invokes the save actor and ends in done or returns to review with an error. account personal | business NEXT → company if business, else → address. company (business only) name, registration number NEXT → address. BACK → account. address street, city, country NEXT → tax if business and country ≠ US, else → review. tax (non-US business only) VAT number NEXT → review. BACK → address. review → submitting SUBMIT invokes the save actor onDone → done; onError → review with a form-level error.

The core pattern: an XState v5 machine

import { assign, fromPromise, setup } from "xstate";

type Answers = {
  accountType?: "personal" | "business";
  company?: { name: string; regNo: string };
  address?: { street: string; city: string; country: string };
  vatNumber?: string;
};

type WizardEvent =
  | { type: "NEXT"; data: Partial<Answers> }
  | { type: "BACK" }
  | { type: "SUBMIT" };

export const wizard = setup({
  types: { context: {} as { answers: Answers; error: string | null }, events: {} as WizardEvent },
  guards: {
    isBusiness: ({ context }) => context.answers.accountType === "business",
    needsTax: ({ context }) =>
      context.answers.accountType === "business" && context.answers.address?.country !== "US",
  },
  actions: {
    // Merge, never replace: answers for steps the user is not currently on
    // must survive, so switching back to "business" restores them.
    save: assign({ answers: ({ context, event }) =>
      event.type === "NEXT" ? { ...context.answers, ...event.data } : context.answers }),
  },
  actors: {
    submit: fromPromise(async ({ input }: { input: Answers }) => {
      const res = await fetch("/api/accounts", { method: "POST", body: JSON.stringify(input) });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    }),
  },
}).createMachine({
  id: "signup",
  initial: "account",
  context: { answers: {}, error: null },
  states: {
    account: {
      on: { NEXT: [
        { guard: "isBusiness", target: "company", actions: "save" },
        { target: "address", actions: "save" },
      ] },
    },
    company: { on: { NEXT: { target: "address", actions: "save" }, BACK: "account" } },
    address: {
      on: {
        NEXT: [
          // Guards see context BEFORE this transition's actions run, so the
          // country submitted with this event is not in context yet: read it
          // from the event instead (see failure mode 1).
          { guard: ({ context, event }) =>
              event.type === "NEXT" && context.answers.accountType === "business" &&
              event.data.address?.country !== "US",
            target: "tax", actions: "save" },
          { target: "review", actions: "save" },
        ],
        BACK: [{ guard: "isBusiness", target: "company" }, { target: "account" }],
      },
    },
    tax: { on: { NEXT: { target: "review", actions: "save" }, BACK: "address" } },
    review: {
      on: {
        SUBMIT: "submitting",
        BACK: [{ guard: "needsTax", target: "tax" }, { target: "address" }],
      },
    },
    submitting: {
      invoke: {
        src: "submit",
        input: ({ context }) => context.answers,
        onDone: "done",
        onError: { target: "review", actions: assign({ error: ({ event }) => String(event.error) }) },
      },
    },
    done: { type: "final" },
  },
});

The component sends { type: "NEXT", data } only after the current step’s fields validate. Validation stays outside the machine — the machine decides where to go, your step schema decides whether you may go.


Step-by-step walkthrough

  1. Make each step a state and each button an event. The machine then rejects impossible sequences — there is no SUBMIT handler on company, so a stray submit from a lingering Enter key does nothing.
  2. Express branching as guarded transitions. An array of transitions is tried in order; the first whose guard passes wins. The unguarded last entry is the default path.
  3. Merge answers into context with assign. Replacing the answers object on each step would lose data for steps not on the current path; merging keeps it for when the user switches back.
  4. Mirror the forward guards on BACK. Back from review goes to tax only if tax is on the path. Deriving both directions from the same guard names keeps them consistent.
  5. Validate before sending NEXT. Run the step’s schema — see validating only the current step — and send the event only with clean data.
  6. Invoke the submit as an actor. invoke starts the promise on entering submitting and cancels interest in it if the state is left, so a user who navigates away does not trigger onDone later.
Paths through the machine by answers A personal account visits account, address, review. A business in the US visits account, company, address, review. A business outside the US visits account, company, address, tax, review. BACK from review leads to address for the first two and to tax for the third, because the review BACK transition uses the same needsTax guard as the forward path. User States visited BACK from review Personal account → address → review address Business, US account → company → address → review address Business, non-US account → company → address → tax → review tax

Failure modes and edge cases

1. Guards see the context before the transition’s actions

In XState, a transition’s guard is evaluated against the current context, before that transition’s assign runs. A guard on the address step that reads context.answers.address.country sees the previous country. Read the just-submitted value from the event, as the address transition does, or split into an intermediate transient state.

2. Answers that became irrelevant still reach the server

If a business user switches to personal on the first step, company and vatNumber remain in context. That is intended — they come back if the user switches again — but the submit input must drop them. Apply the relevance rule from what happens to errors when a field is hidden in the actor’s input.

3. Browser history

The machine owns step state; the URL does not know about it, so the browser Back button leaves the wizard entirely. Sync them deliberately, as in syncing wizard steps with browser history.

4. Persisting a machine across reloads

XState v5 actors can return a persisted snapshot with actor.getPersistedSnapshot() and restore with createActor(machine, { snapshot }). Snapshots taken while in submitting restore into a state whose promise no longer exists — persist only from step states, and restore submitting as review.

5. Focus after each transition

Every transition replaces the visible step. Move focus to the new step’s heading on each transition, as described in focus management in multi-step wizards, or keyboard and screen-reader users are left on a button that no longer exists.

What lives where The machine owns the current step, the path decisions through guards and the submit lifecycle. The step schemas own whether the current step's data is valid and produce the data sent with NEXT. The view renders the current step, sends events, moves focus on each transition and syncs the URL. Machine Current step. Path via guards. Submit lifecycle via invoke. Step schemas Is this step valid? Clean data for NEXT. No knowledge of the path. View Renders the step. Sends events. Moves focus; syncs the URL.

Verification checklist


Frequently Asked Questions

Is XState overkill for a three-step wizard?

For a linear three-step form, a step index and a reducer are enough. The machine earns its keep when paths branch, when back navigation must mirror forward decisions, and when submission has states of its own. The moment you write a second if in a Next handler, it is worth considering.

Should field values live in the machine context?

Store committed step answers in context and keep in-progress field state in the step’s own form. That keeps the machine free of per-keystroke updates and makes each step’s form reusable, while context remains the single record of what the user has confirmed.

How do I test the branching?

Create an actor, send a sequence of events with representative data, and assert actor.getSnapshot().value after each. Because the machine is pure logic, these tests need no DOM and run in milliseconds; add one per path in the table above.


Related

← Multi-Step Form State Machines