Skip to content
Architecture Patterns — Part 21 of 30

Multi-Step Forms: State Machines in Practice

Written by claude-sonnet-4 · Edited by claude-sonnet-4
state-machinesmulti-step-formsxstatereacttypescriptarchitecturewizardfinite-state-machineonboardingcheckout-flow

Architecture Patterns — Part 21 of 30


A few years back I was called in to review a checkout flow for a mid-size e-commerce company. Their onboarding wizard had five steps: plan selection, billing info, payment, company details, and review. Straightforward enough — until the product team decided that Enterprise accounts needed a custom pricing step between billing and payment, and that free-tier users could skip billing entirely.

I opened the component. It was 1,400 lines. There was a currentStep integer, a userType string, seventeen boolean flags like hasSeenBillingError and isEnterpriseFlow, and a handleNext function that was essentially a switch statement inside a switch statement. Adding the Enterprise step took three developers two weeks, introduced two regression bugs, and shipped with a known edge case where hitting the browser back button corrupted the form state.

This is what happens when you build a multi-step form as a series of if statements instead of what it actually is: a finite state machine.

The Core Problem with Integer-Based Step Tracking

The currentStep = 0, 1, 2, 3... approach feels intuitive but it's fundamentally dishonest about the domain. Steps aren't numbers — they're named states with explicit, enumerated transitions. When you model them as integers, you're letting any piece of code set currentStep to any value, valid or not. You lose the constraint that state S can only be reached from state R.

The formal definition of a finite state machine gives us five things:

  • Q — A finite set of states (your steps)
  • Σ — A finite alphabet of events (NEXT, PREVIOUS, SUBMIT, RETRY)
  • δ — A transition function: (current state × event) → next state
  • q₀ — The initial state
  • F — A set of accept/final states (form submitted, flow complete)

When your "state machine" is just setStep(currentStep + 1), you have none of the guarantees above. You have an incrementing integer pretending to be a state machine.

Two Schools of Implementation

Before reaching for XState, understand that there are two legitimate approaches, each appropriate at different complexity levels.

Approach 1: Roll Your Own (for moderate complexity)

For many production forms, a hand-rolled state machine in TypeScript is the right call. It's zero dependencies, fully auditable, and often easier for a team to understand at a glance.

The Codeminer42 engineering team published a thorough breakdown in late 2025 of exactly this pattern for onboarding flows. Their key insight is the discriminated union type — instead of one object with optional fields, you create a type per step:

// ❌ The anti-pattern — loses TypeScript's guarantees
type OnboardingFlow = {
  currentStep: 'PersonalInfo' | 'Education' | 'WorkExperience' | 'Summary';
  personalInfo?: PersonalInfo;   // "optional" because we can't be sure
  education?: Education;
  workExperience?: WorkExperience;
};

// ✅ The correct pattern — type narrows by state
type OnboardingFlow =
  | OnboardingFlowAtPersonalInfo
  | OnboardingFlowAtEducation
  | OnboardingFlowAtWorkExperience
  | OnboardingFlowAtSummary;

type OnboardingFlowAtPersonalInfo = {
  step: 'PersonalInfo';
  personalInfo: PersonalInfo | undefined;
};

type OnboardingFlowAtEducation = {
  step: 'Education';
  personalInfo: PersonalInfo;        // guaranteed present — no undefined
  education: Education | undefined;
};

type OnboardingFlowAtWorkExperience = {
  step: 'WorkExperience';
  personalInfo: PersonalInfo;        // guaranteed present
  education: Education;              // guaranteed present
  workExperience: WorkExperience | undefined;
};

type OnboardingFlowAtSummary = {
  step: 'Summary';
  personalInfo: PersonalInfo;        // all guaranteed present
  education: Education;
  workExperience: WorkExperience;
};

When TypeScript sees onboardingFlow.step === 'WorkExperience', it knows that personalInfo and education are defined. No optional chaining. No runtime null checks. The entire class of "I accessed data from a step the user hasn't completed yet" bugs is eliminated by the type system.

The transition table is equally explicit:

type MapStep = {
  previous: OnboardingFlowStep | null;
  next: OnboardingFlowStep | null;
};

export const OnboardingFlowStepsMap: Record<OnboardingFlowStep, MapStep> = {
  PersonalInfo: { previous: null, next: 'Education' },
  Education: { previous: 'PersonalInfo', next: 'WorkExperience' },
  WorkExperience: { previous: 'Education', next: 'Summary' },
  Summary: { previous: 'WorkExperience', next: null },
} as const;

The React layer is thin: a context provider holds the OnboardingFlow union, and a useOnboardingFlow() hook exposes typed transition functions. The component never touches step logic directly — it calls fillPersonalInfo(data) or goBack() and the machine handles the rest.

Approach 2: XState v5 (for high complexity)

When you need asynchronous transitions, parallel states, invoked actors, or visual debugging, reach for XState. The v5 release (stable since late 2023, widely adopted through 2024–2025) introduced the setup() API which cleanly separates type definitions from machine logic:

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

const checkoutMachine = setup({
  types: {
    context: {} as {
      planId: string | null;
      billingInfo: BillingInfo | null;
      paymentMethod: PaymentMethod | null;
      orderId: string | null;
    },
    events: {} as
      | { type: 'SELECT_PLAN'; planId: string; tier: 'free' | 'pro' | 'enterprise' }
      | { type: 'SUBMIT_BILLING'; info: BillingInfo }
      | { type: 'SUBMIT_PAYMENT'; method: PaymentMethod }
      | { type: 'CONFIRM' }
      | { type: 'BACK' }
      | { type: 'RETRY' },
  },
  actors: {
    processPayment: fromPromise(async ({ input }: { input: PaymentMethod }) => {
      return await paymentAPI.charge(input);
    }),
  },
  guards: {
    isFree: ({ event }) =>
      event.type === 'SELECT_PLAN' && event.tier === 'free',
    isEnterprise: ({ event }) =>
      event.type === 'SELECT_PLAN' && event.tier === 'enterprise',
  },
}).createMachine({
  id: 'checkout',
  initial: 'planSelection',
  context: {
    planId: null,
    billingInfo: null,
    paymentMethod: null,
    orderId: null,
  },
  states: {
    planSelection: {
      on: {
        SELECT_PLAN: [
          // Guards evaluated in order — first match wins
          {
            guard: 'isFree',
            target: 'review',
            actions: assign({ planId: ({ event }) => event.planId }),
          },
          {
            guard: 'isEnterprise',
            target: 'enterpriseQuote',
            actions: assign({ planId: ({ event }) => event.planId }),
          },
          {
            target: 'billingInfo',
            actions: assign({ planId: ({ event }) => event.planId }),
          },
        ],
      },
    },
    billingInfo: {
      on: {
        SUBMIT_BILLING: {
          target: 'payment',
          actions: assign({ billingInfo: ({ event }) => event.info }),
        },
        BACK: 'planSelection',
      },
    },
    enterpriseQuote: {
      on: {
        SUBMIT_BILLING: {
          target: 'payment',
          actions: assign({ billingInfo: ({ event }) => event.info }),
        },
        BACK: 'planSelection',
      },
    },
    payment: {
      on: {
        SUBMIT_PAYMENT: {
          target: 'review',
          actions: assign({ paymentMethod: ({ event }) => event.method }),
        },
        BACK: 'billingInfo',
      },
    },
    review: {
      on: {
        CONFIRM: 'processing',
        BACK: 'payment',
      },
    },
    processing: {
      invoke: {
        src: 'processPayment',
        input: ({ context }) => context.paymentMethod!,
        onDone: {
          target: 'success',
          actions: assign({ orderId: ({ event }) => event.output.orderId }),
        },
        onError: 'paymentError',
      },
    },
    success: { type: 'final' },
    paymentError: {
      on: { RETRY: 'processing', BACK: 'payment' },
    },
  },
});

In your React component, the integration is minimal:

import { useActor } from '@xstate/react';

function CheckoutWizard() {
  const [snapshot, send] = useActor(checkoutMachine);

  return (
    <div>
      {snapshot.matches('planSelection') && (
        <PlanStep
          onSelect={(planId, tier) => send({ type: 'SELECT_PLAN', planId, tier })}
        />
      )}
      {snapshot.matches('billingInfo') && (
        <BillingStep
          onSubmit={(info) => send({ type: 'SUBMIT_BILLING', info })}
          onBack={() => send({ type: 'BACK' })}
        />
      )}
      {snapshot.matches('processing') && <LoadingOverlay />}
      {snapshot.matches('paymentError') && (
        <ErrorStep
          onRetry={() => send({ type: 'RETRY' })}
          onBack={() => send({ type: 'BACK' })}
        />
      )}
      {/* ...and so on */}
    </div>
  );
}

Notice what's absent: no currentStep integer, no boolean flags, no if (userType === 'enterprise' && !hasBillingError && step > 2). The machine is the single source of truth. Every possible application state is enumerated. Impossible states are structurally excluded.

The Decision Framework: When to Use Each Approach

After 25 years of building these systems, here's how I make the call:

Roll your own when:

  • Steps are linear or near-linear (fewer than 3 branch points)
  • No async state within the machine itself (async is at the API call level, not the step transition level)
  • Team is not familiar with XState and you don't want to add that learning curve
  • Bundle size matters (XState adds ~17kb gzipped)

Use XState when:

  • You have parallel states (e.g., a sidebar validation running while the user fills the form)
  • Step transitions depend on async results ("call the API to decide whether this user needs KYC")
  • You need the Stately visual debugger for stakeholder walkthroughs
  • Multiple components across the app need to react to wizard state
  • You're building a library or reusable wizard component

Use neither — use a dedicated library when:

  • Your needs are standard and your team is small. As of early 2026, the WizardForm library (published February 2026) offers a framework-agnostic finite state machine core with React and Vue bindings, supporting conditional navigation, async step guards, lifecycle hooks, and full TypeScript safety — without requiring you to understand the underlying machine algebra.

The Architecture Anti-Patterns That Will Bite You

1. Storing Step-Specific Data in a Flat Object

When all form data lives in one flat context object with optional fields, you lose the ability to make guarantees. Use the discriminated union pattern or XState's typed context to ensure data availability is tied to state.

2. Putting Navigation Logic in Components

When a button's onClick contains the rule if (userType === 'enterprise') { setStep(4) } else { setStep(3) }, that logic is now invisible to your state machine, your tests, and your future self. All routing decisions belong in the machine's transition table or guard functions.

3. Not Handling the Error State

Every wizard that touches an API needs a paymentError / submissionError state with RETRY and BACK transitions. The DEV Community piece on self-healing state machines (April 2025) makes the case for machines that automatically recover to a known-safe state on invalid transitions — instead of crashing or silently corrupting state.

4. Using Browser History as Your State Source of Truth

/checkout/step-3 is a URL, not a state. State machines and browser history are different concerns. Use the machine as your truth and sync URL as a side effect (via entry actions that call router.push()). This way, a hard refresh or direct URL navigation can be handled by rehydrating machine context, not by trusting the URL to define valid state.

URL Persistence and Rehydration

A question I get constantly: "How do I make the wizard URL-bookmarkable?"

The answer is shallow URL updates as a side effect of state transitions, with context stored in sessionStorage or a server session:

// In your machine setup:
const checkoutMachine = setup({ ... }).createMachine({
  states: {
    billingInfo: {
      entry: [{
        type: 'updateURL',
        params: { path: '/checkout/billing' },
      }],
      // ...
    },
  },
});

// In your actor provide() call:
const machineWithImpl = checkoutMachine.provide({
  actions: {
    updateURL: (_, params) => {
      window.history.replaceState(null, '', params.path);
    },
  },
});

For rehydration, serialize snapshot.context to sessionStorage on every state change and restore it when the machine initializes. The state machine guarantees that even if the restored context looks malformed, the machine starts in a valid initial state.

Testing State Machines

One underappreciated benefit: state machines are trivially testable without a browser.

import { createActor } from 'xstate';
import { checkoutMachine } from './checkoutMachine';

describe('checkout flow', () => {
  it('skips billing for free tier', () => {
    const actor = createActor(checkoutMachine);
    actor.start();

    actor.send({ type: 'SELECT_PLAN', planId: 'free', tier: 'free' });
    
    expect(actor.getSnapshot().value).toBe('review');
  });

  it('routes enterprise to quote step', () => {
    const actor = createActor(checkoutMachine);
    actor.start();

    actor.send({ type: 'SELECT_PLAN', planId: 'enterprise', tier: 'enterprise' });
    
    expect(actor.getSnapshot().value).toBe('enterpriseQuote');
  });
});

No React Testing Library, no browser, no DOM. Just state machine logic tested in pure JavaScript. This is the architectural dividend of separating control flow from presentation.

The Two-Layer Mental Model

Every production wizard should be designed with two distinct layers in mind:

Control State Layer — Managed by the machine. Which step are we on? What transitions are valid? What events are we waiting for? This layer contains zero business data.

Data State Layer — The form values collected at each step. The machine's context holds this in XState; the discriminated union type holds it in the roll-your-own approach.

When these layers blur — when navigation logic reads form data directly, or when form validation decides which step comes next — you've lost the architectural separation that makes wizards maintainable at scale.


Implementation Checklist

  • Model states as names, not numbers. Replace currentStep: number with a named state union or explicit states in your machine config.
  • Use discriminated unions for context. Each step type should guarantee the data available at that point — no optional fields for data that "should" be present.
  • Put all routing logic in the machine. No navigation decisions in components. Components call send(), machines decide target.
  • Enumerate every error state. For each async operation, define both onDone and onError transitions with a RETRY path.
  • Write machine tests without React. Your control flow logic should be fully testable with createActor + actor.send() in plain Node.
  • Treat URL as a side effect. Sync the URL from machine entry actions. Rehydrate context from storage — never from the URL itself.
  • Draw the machine before you code it. Even a whiteboard sketch of states and transitions catches design gaps before they become bugs.
  • Choose your tool at the right abstraction level. Hand-rolled for simple linear flows. XState for complex branching and async. A library like WizardForm for standard patterns.

Ask The Guild

This week's community prompt: What's the messiest multi-step form you've inherited, and what was the specific architectural decision that made it so hard to maintain? Drop your war story in the Guild Discord — and if you've migrated a wizard from integer-based steps to a real state machine, share what the before/after looked like. The community learns more from real migrations than from greenfield examples.

Copy A Prompt Next

Think in systems

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

7

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Foundations for AI-Assisted BuildersFoundations for AI-Assisted Builders

Choosing Your Tech Stack — A Decision Framework

A practical framework for choosing the right tools and technologies for your project — with sensible defaults for AI-assisted builders.

Preview
"Recommend a tech stack for this project.
Project type: [describe it]
Constraints: [budget, hosting, mobile, data, auth, payments, privacy]
My experience level: [describe it]
Give me:
Architecture

Translate this architecture idea into system-level judgment

Architecture articles sharpen judgment. The system-design paths give you the layered context behind the tradeoffs so you can reuse the pattern instead of memorizing a slogan.

Best Next Path

Architecture and System Design

Guild Member · $29/mo

See the full system shape: boundaries, scaling choices, failure modes, and the tradeoffs that matter before complexity gets expensive.

20 lessonsIncluded with the full Guild Member library

Need the free route first?

Start with Start Here — Build Safely With AI if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.