Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 10 of 15BeginnerAug 3, 2026

Multi-Step Form

Design a wizard with one centralised state store, per-step validation with cross-step dependencies, conditional branching, draft persistence, and correct focus on every transition.

frontend-system-designpractice-problemforms

The Problem

Design a multi-step form: a checkout, an onboarding flow, an application. Several screens of fields, a progress indicator, forward and back navigation, validation, and submission at the end.

It is the most common real-world form pattern and it looks like the simplest problem in this track. The reason it is worth designing carefully is that the obvious implementation - each step a component owning its own fields - loses the user's data the moment they navigate backwards, and the second-most-obvious - one URL for the whole wizard - breaks the back button in the way users find most alarming. Both mistakes are architectural, and both are cheap to avoid at design time and expensive to retrofit.

Requirements

Functional

  • Multiple steps, with forward and backward navigation that never loses entered data.
  • Per-step validation gating progress, plus full validation before submit.
  • Conditional steps, where answers determine which steps appear.
  • Progress indicator showing position and completion.
  • Draft persistence, so a reload or a return the next day resumes rather than restarts.
  • Single submission at the end, with a clear error path.

Non-functional

  • Browser back and forward behave as the user expects.
  • A step is linkable and refreshable without breaking the flow.
  • Every transition is announced, and focus lands somewhere useful.
  • Sensitive fields are never written to disk.

Centralised State

The decision that determines everything else.

Per-step state - each step component holding its own fields - fails immediately. React unmounts a component when it stops rendering, and unmounting destroys its state. A user who fills step three, returns to step two to fix a typo, and goes forward again finds step three empty. Keeping every step mounted and hidden avoids the unmount but introduces its own problems: hidden inputs still participate in form submission and in the accessibility tree unless carefully managed, and the DOM grows with every step.

Centralised state puts the data in one store above the step components. Steps become views over shared state, freely mounted and unmounted.

Diagram
100%
flowchart TB subgraph STORE["Form store - single owner, outlives every step mount"] DATA[("values<br/>{ account, shipping, payment, ... }")] META["visitedSteps, touchedFields,<br/>errors, currentStepId, submitState"] DERIVED["derived:<br/>stepSequence (from values)<br/>isStepValid, canAdvance,<br/>progressPercent"] end subgraph STEPS["Step components - pure views, mount and unmount freely"] S1["AccountStep<br/>reads values.account"] S2["ShippingStep<br/>reads values.shipping"] S3["PaymentStep<br/>reads values.payment"] end subgraph PERSIST["Persistence layer - debounced, field-filtered"] LOCAL[("sessionStorage / localStorage<br/>anonymous drafts")] SERVER[("server draft record<br/>authenticated, cross-device")] EXCLUDE["excluded from both:<br/>card number, CVV,<br/>password, national id"] end ROUTER["Router - /checkout/[step]<br/>URL is the source of truth<br/>for which step is showing"] ROUTER -->|"step param"| META STORE --> DERIVED DERIVED --> STEPS STEPS -->|"setField(path, value)"| DATA DATA -->|"debounced 800ms"| PERSIST PERSIST -->|"hydrate on mount"| DATA DERIVED -->|"guard: redirect to first<br/>incomplete step"| ROUTER style STORE fill:#1e3f2d,stroke:#22c55e style STEPS fill:#1e3a5f,stroke:#3b82f6 style PERSIST fill:#3f2d1e,stroke:#f59e0b style EXCLUDE fill:#3f1e1e,stroke:#ef4444
visualized byIOCombats
type FormValues = {
  account: { email: string; password: string };
  shipping: { line1: string; city: string; postcode: string; country: string };
  delivery: { method: 'standard' | 'express' | 'pickup' | null; date: string | null };
  payment: { token: string | null; billingSameAsShipping: boolean };
};

type FormState = {
  values: FormValues;
  /** Steps the user has reached - drives which errors may be shown. */
  visited: Set<StepId>;
  /** Fields the user has interacted with - drives per-field error display. */
  touched: Set<string>;
  errors: Record<string, string[]>;
  submitState: 'idle' | 'submitting' | 'failed' | 'succeeded';
};

Three modelling decisions worth defending.

values is a nested object keyed by step, not a flat bag. It makes per-step validation a matter of validating one slice, and it makes the serialised draft self-describing.

visited and touched are separate from values. They are what let you show an error only after the user has had a chance to make it. Showing "email is required" on a field nobody has typed into yet is the most common form annoyance, and the fix is state, not conditionals in the view.

Submission state lives here too, so a step can disable its own controls while the submit is in flight without prop-drilling.

The store belongs in whichever mechanism the app already uses - Context with a reducer for a self-contained flow, Zustand or Redux when the form interacts with the rest of the app, or react-hook-form with a single FormProvider spanning all steps, which is the most common production answer because it also gives you field registration and validation wiring. The categorisation argument - this is client-owned state with a persistence side-channel, not server state - is in State Management Architecture.

Step Sequence and Conditional Branching

A wizard's steps are rarely a fixed list. Choosing "pick up in store" removes the shipping step; a business account adds a tax details step. Deriving the sequence from data keeps that logic in one place.

type StepId = 'account' | 'shipping' | 'delivery' | 'taxDetails' | 'payment' | 'review';

type StepDefinition = {
  id: StepId;
  title: string;
  /** Absent means always included. */
  isRequired?: (values: FormValues) => boolean;
  validate: (values: FormValues) => Record<string, string[]>;
};

const STEPS: StepDefinition[] = [
  { id: 'account', title: 'Your account', validate: validateAccount },
  {
    id: 'delivery',
    title: 'Delivery method',
    validate: validateDelivery,
  },
  {
    id: 'shipping',
    title: 'Shipping address',
    // Not needed when collecting in store.
    isRequired: (values) => values.delivery.method !== 'pickup',
    validate: validateShipping,
  },
  {
    id: 'taxDetails',
    title: 'Tax details',
    isRequired: (values) => values.account.email.endsWith('.gov'),
    validate: validateTaxDetails,
  },
  { id: 'payment', title: 'Payment', validate: validatePayment },
  { id: 'review', title: 'Review and confirm', validate: () => ({}) },
];

/** The single source of truth for "which steps are in this flow right now". */
function getStepSequence(values: FormValues): StepDefinition[] {
  return STEPS.filter((step) => step.isRequired?.(values) ?? true);
}
Diagram
100%
stateDiagram-v2 [*] --> Account Account --> Delivery: valid Account --> Account: invalid - focus first error Delivery --> Shipping: method is standard or express Delivery --> Payment: method is pickup<br/>(shipping step removed) Shipping --> TaxDetails: email domain requires it Shipping --> Payment: otherwise TaxDetails --> Payment: valid Payment --> Review: token obtained Payment --> Payment: card declined Review --> Submitting: confirm Submitting --> Succeeded: 201 Submitting --> Review: server validation failed<br/>(errors mapped back to their steps) Submitting --> Review: network error - retry allowed Succeeded --> [*]: draft cleared note right of Delivery Changing the method AFTER shipping was completed must prune the now-orphaned shipping values, or a stale address is submitted. end note note right of Review Server errors can belong to any step. Mapping them back and offering "fix this" links is what stops the user hunting through six screens. end note
visualized byIOCombats

The note on Delivery marks the branching bug that is easy to miss. A user completes shipping, then goes back and switches to pickup. The shipping step disappears from the sequence, but its values are still in the store and will be submitted. Two possible policies, and the design must choose one deliberately:

function setDeliveryMethod(state: FormState, method: DeliveryMethod): FormState {
  const wasIncluded = getStepSequence(state.values).some((step) => step.id === 'shipping');
  const values = { ...state.values, delivery: { ...state.values.delivery, method } };
  const isIncluded = getStepSequence(values).some((step) => step.id === 'shipping');

  if (wasIncluded && !isIncluded) {
    // Prune values belonging to a step that has left the flow, and drop it
    // from `visited` so returning to it does not show it as complete.
    return {
      ...state,
      values: { ...values, shipping: emptyShipping() },
      visited: without(state.visited, 'shipping'),
    };
  }

  return { ...state, values };
}

Pruning is the safer default because it guarantees nothing orphaned is submitted. Preserving the values is friendlier when a user is likely to switch back, but then they must be excluded at submission time rather than merely hidden - and "hidden but submitted" is exactly the bug pruning prevents.

Validation

Three layers, each with a different job.

Per-field, on blur, once the field is touched. Immediate feedback without punishing someone mid-typing.

Per-step, on attempting to advance. This is the gate that stops a user carrying an error five steps forward.

function tryAdvance(state: FormState, currentId: StepId): AdvanceResult {
  const sequence = getStepSequence(state.values);
  const step = sequence.find((item) => item.id === currentId)!;

  const errors = step.validate(state.values);
  // Cross-step rules re-run here too, because a value entered earlier can be
  // invalidated by something chosen on this step.
  const crossStep = validateCrossStep(state.values);
  const combined = mergeErrors(errors, crossStep);

  if (Object.keys(combined).length > 0) {
    return { ok: false, errors: combined, focusField: firstFieldOf(combined) };
  }

  const index = sequence.indexOf(step);
  const next = sequence[index + 1];
  return next ? { ok: true, nextStepId: next.id } : { ok: true, done: true };
}

Cross-step rules are the ones per-step validation cannot express, and they need their own home:

function validateCrossStep(values: FormValues): Record<string, string[]> {
  const errors: Record<string, string[]> = {};

  // Express delivery is not available to every destination - a shipping value
  // validated on its own step, invalidated by a choice made on another.
  if (
    values.delivery.method === 'express' &&
    !EXPRESS_COUNTRIES.includes(values.shipping.country)
  ) {
    errors['delivery.method'] = [
      'Express delivery is not available for this destination.',
    ];
  }

  if (
    values.delivery.date &&
    values.delivery.method === 'standard' &&
    isBefore(values.delivery.date, earliestStandardDate())
  ) {
    errors['delivery.date'] = ['Standard delivery cannot arrive that soon.'];
  }

  return errors;
}

The important property: cross-step rules re-run whenever any of their inputs change, not only on the step that owns the field. A user who changes country on step two must see the express-delivery error even though the offending value lives on step three. Running them only on the owning step is how a form lets an invalid combination reach the server.

Server validation is the only enforcement. Everything above is a user-experience feature that runs in an environment the user controls. The server revalidates the entire payload, and its errors must be mapped back to the steps that own them so the user can be sent to the right screen rather than shown an opaque failure on the review page:

function applyServerErrors(state: FormState, serverErrors: ServerError[]): FormState {
  const errors: Record<string, string[]> = {};
  const affectedSteps = new Set<StepId>();

  for (const error of serverErrors) {
    errors[error.field] = [error.message];
    // "shipping.postcode" -> the shipping step.
    affectedSteps.add(error.field.split('.')[0] as StepId);
  }

  return { ...state, errors, submitState: 'failed', affectedSteps };
}

The reasoning for why client validation cannot be trusted, and what the server must independently check, is in Security Architecture.

Routing and the Back Button

One URL for the whole wizard breaks the back button in the worst possible way: pressing back exits the form entirely, discarding everything. Users press back constantly - it is how they expect to go up a step.

Give each step a route:

/checkout/account
/checkout/delivery
/checkout/shipping
/checkout/payment
/checkout/review
// app/(public)/checkout/[step]/page.tsx
export default function CheckoutStepPage({ params }: { params: { step: string } }) {
  return <CheckoutFlow stepId={params.step as StepId} />;
}
function useStepGuard(stepId: StepId, state: FormState) {
  const router = useRouter();

  useEffect(() => {
    const sequence = getStepSequence(state.values);
    const target = sequence.find((step) => step.id === stepId);

    // A step that is not in the current sequence - a deep link to /shipping
    // after choosing pickup, or a renamed step in a stale bookmark.
    if (!target) {
      router.replace(`/checkout/${sequence[0].id}`);
      return;
    }

    // Every earlier step must be complete. Otherwise a fresh session deep-linked
    // to /payment renders a step whose prerequisites were never met.
    const index = sequence.indexOf(target);
    const firstIncomplete = sequence.findIndex(
      (step) => Object.keys(step.validate(state.values)).length > 0,
    );

    if (firstIncomplete !== -1 && firstIncomplete < index) {
      // replace, not push: the invalid URL should not become a history entry
      // the user can press back into.
      router.replace(`/checkout/${sequence[firstIncomplete].id}`);
    }
  }, [stepId, state.values, router]);
}

Four consequences of routing per step, all of them good:

  • Back and forward work natively. No history interception, no custom stack.
  • Steps are linkable and refreshable, which is what makes draft persistence load-bearing rather than a nicety.
  • Analytics attribute drop-off to a step. A single-URL wizard reports one pageview and tells you nothing about where people abandon.
  • replace rather than push for guard redirects, so an invalid URL does not become a history entry the user can bounce back into.

The one thing you must add is an unsaved-changes guard, and it needs both halves:

useEffect(() => {
  if (!isDirty || submitState === 'succeeded') return;

  const warn = (event: BeforeUnloadEvent) => {
    event.preventDefault();
    event.returnValue = ''; // browsers show their own generic message
  };

  window.addEventListener('beforeunload', warn);
  return () => window.removeEventListener('beforeunload', warn);
}, [isDirty, submitState]);

beforeunload covers leaving the site; in-app navigation away from the flow needs the router's own interception. Neither is a guarantee - persistence is what actually protects the data.

Draft Persistence

Two options, chosen by who the user is.

const DRAFT_KEY = 'checkout-draft-v1';
const PERSIST_DEBOUNCE_MS = 800;

/** Fields that must never be written to disk, in any storage. */
const EXCLUDED_PATHS = [
  'account.password',
  'payment.cardNumber',
  'payment.cvv',
  'payment.rawPan',
];

function toDraft(values: FormValues): unknown {
  const draft = structuredClone(values) as Record<string, never>;
  for (const path of EXCLUDED_PATHS) unset(draft, path);
  // Versioned so a schema change can migrate or discard rather than crash.
  return { version: 1, savedAt: Date.now(), values: draft };
}

const persistDraft = debounce((values: FormValues) => {
  try {
    sessionStorage.setItem(DRAFT_KEY, JSON.stringify(toDraft(values)));
  } catch {
    // Quota exceeded or storage blocked (private mode, cookie policy).
    // A failed draft save must never break the form.
  }
}, PERSIST_DEBOUNCE_MS);

function loadDraft(): Partial<FormValues> | null {
  try {
    const raw = sessionStorage.getItem(DRAFT_KEY);
    if (!raw) return null;

    const parsed = JSON.parse(raw) as { version: number; values: unknown };
    if (parsed.version !== 1) {
      sessionStorage.removeItem(DRAFT_KEY);
      return null;
    }

    // Validate rather than trust: the draft may have been edited by hand or
    // written by an older build with a different shape.
    const result = DraftSchema.safeParse(parsed.values);
    return result.success ? result.data : null;
  } catch {
    return null;
  }
}

Five rules that matter more than the storage choice:

  1. Exclude sensitive fields explicitly. Card numbers, CVVs and passwords must never touch disk. A payment step should hold a provider token, not raw values, which sidesteps the problem entirely and keeps card data out of your application's scope.
  2. Version the draft. The form's shape will change; a version lets you migrate or discard rather than crash on a stale draft.
  3. Validate on load. A persisted draft is untrusted input - it can be hand-edited or written by an older build.
  4. Debounce writes. Serialising on every keystroke is main-thread work for nothing.
  5. Never let a storage failure break the form. Quota limits and private-mode restrictions are routine; the form must degrade to non-persistent rather than throw.

sessionStorage versus localStorage is a product decision: session-scoped if abandoning the tab should discard the draft, longer-lived if the user should find their progress days later. For authenticated flows a server-side draft is better - it survives a device change, can be resumed on another machine, and lets you email a resume link - at the cost of a debounced autosave endpoint and a cleanup job. The durable-queue reasoning behind all of this is in Offline and PWA Architecture.

And when a draft is loaded, say so. Silently prefilling a form with data from last week is disconcerting; a dismissible "We restored your progress from Tuesday - start over?" is not.

Accessibility of Step Transitions

This is where multi-step forms most commonly fail, and the failure is invisible to anyone testing with a mouse. Clicking Next replaces the visible fields, but focus stays on the button - which may no longer exist - and nothing is announced. A screen reader user has no idea the content changed.

function StepContainer({ step, index, total }: StepContainerProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    // Move focus to the new step's heading so the next thing read is the step
    // title, not silence. tabIndex -1 makes a non-interactive element
    // focusable without adding it to the tab order.
    headingRef.current?.focus();
    document.title = `${step.title} - Step ${index + 1} of ${total} - Checkout`;
  }, [step.id, index, total, step.title]);

  return (
    <section aria-labelledby='step-heading'>
      <h2 id='step-heading' ref={headingRef} tabIndex={-1}>
        {step.title}
      </h2>
      <p className='text-sm text-muted-foreground'>
        Step {index + 1} of {total}
      </p>
      {/* fields */}
    </section>
  );
}

The progress indicator needs semantics, not just pixels:

<nav aria-label='Checkout progress'>
  <ol className='flex gap-2'>
    {sequence.map((step, index) => {
      const isCurrent = step.id === currentId;
      const isComplete = visited.has(step.id) && !hasErrors(step.id);

      return (
        <li key={step.id}>
          {isComplete && !isCurrent ? (
            // Completed steps are navigable; future ones are not, because their
            // prerequisites are not met.
            <Link href={`/checkout/${step.id}`}>
              <Check aria-hidden className='h-3 w-3' />
              {step.title}
              <span className='sr-only'>, completed</span>
            </Link>
          ) : (
            <span aria-current={isCurrent ? 'step' : undefined}>
              {step.title}
              {isCurrent && <span className='sr-only'>, current step</span>}
            </span>
          )}
        </li>
      );
    })}
  </ol>
</nav>

Errors need two channels, because a visual error next to a field is invisible to a screen reader user who is elsewhere on the page:

{/* Assertive is justified here: the user just tried to advance and was blocked,
    so interrupting is the correct behaviour. */}
<div role='alert' aria-live='assertive'>
  {blockedCount > 0 &&
    `Cannot continue. ${blockedCount} field${blockedCount === 1 ? '' : 's'} need attention.`}
</div>

<input
  id='shipping-postcode'
  aria-invalid={Boolean(errors['shipping.postcode'])}
  aria-describedby={
    errors['shipping.postcode'] ? 'shipping-postcode-error' : undefined
  }
/>
{errors['shipping.postcode'] && (
  <p id='shipping-postcode-error' className='text-sm text-red-500'>
    {errors['shipping.postcode'][0]}
  </p>
)}

Plus focus the first invalid field when advancing is blocked, so the user is taken to the problem rather than told one exists somewhere. Four requirements in total - focus the heading on transition, aria-current="step" on the indicator, errors in a live region and associated with their fields, and focus moved to the first error - and each of them is a few lines. The general patterns are in Accessibility Architecture.

Common Interview Follow-Up Questions

"The final submit fails with a server validation error on step two. What does the user see?" Not a generic failure on the review page. Map each server error to the step that owns the field, show a summary on the review step listing what is wrong with a link per item ("Postcode is not valid for the selected country - fix in Shipping"), and let the user jump directly there with the error already displayed and focus on the field. When they correct it, they should return to review rather than walking forward through every step again, which means the flow needs a notion of "returning to review after a fix". A form that makes the user hunt through six screens for an unspecified problem is where long-form abandonment happens.

"How do you handle a step that needs server data - available delivery dates, address validation?" That data is server state and belongs in the query cache rather than in the form store, which holds only what the user typed. Prefetch it as the user approaches the step so the transition does not stall, render the step with the fields disabled and a skeleton while it loads rather than showing an empty picker, and treat a failure as a step-level error with retry rather than blocking the whole form. Address autocomplete is the interesting case, because it is a typeahead inside a form step and inherits everything from Autocomplete / Typeahead - debouncing, cancellation and the combobox pattern. The split between client-owned and server-owned state is the core argument of State Management Architecture.

"A user starts on desktop and finishes on mobile. Possible?" Only with a server-side draft, since localStorage is per-device and per-browser. For an authenticated user, debounce-autosave the draft to a record keyed by user and flow, and resume from it on any device - which also lets you send a "you left something unfinished" email with a resume link. For an anonymous user you need a draft token in the URL, which then has to be treated as a bearer credential with a short expiry, because anyone holding the link can read the draft. That is a real tradeoff to state rather than hand-wave: cross-device resume for anonymous users means a shareable secret containing personal data.

"How do you prevent duplicate submissions?" Three layers. Disable the submit control and set submitState to submitting the moment it is clicked, which handles the double-click. Generate an idempotency key when the flow starts and send it with the submission, so a retry after a lost response returns the original result rather than creating a second order - this is what protects you when the request succeeds but the response never arrives, which no amount of client-side disabling can fix. And on success, clear the draft and navigate to a confirmation route with replace, so pressing back cannot re-post. The lost-acknowledgement problem is the same one solved in File Upload System.

"How do you test a multi-step form?" The reducer and the sequence derivation are pure and get the deepest coverage - branch selection for each combination of inputs, pruning when a step leaves the sequence, cross-step validation firing from either side, and server errors mapping back to steps. Persistence needs tests for a stale draft version, a hand-corrupted draft, storage throwing, and the assertion that excluded fields are absent from what was written - that last one is a security test and worth naming as such. Then one integration test per branch walking the whole flow with the keyboard only, asserting focus lands on each heading and that a blocked advance focuses the first invalid field. The layering rationale is in Testing Strategy.

Tradeoffs Table

OptionProsConsWhen to Use
Per-step local stateTrivial, fully encapsulated stepsData is destroyed on unmount; no cross-step validation; nothing to serialiseNever for a multi-step flow; fine for a single-screen form
Centralised form storeSurvives navigation, enables cross-step rules and drafts, steps stay pureOne more layer, and a schema to keep in step with the UIDefault for any wizard
One URL for the whole wizardSimplest routing, no guards neededBack button exits and discards everything; no per-step analytics or linkingOnly for a two-step flow inside a modal
Route per stepNative back and forward, linkable, refreshable, per-step analyticsNeeds guards and state that survives navigationDefault for any wizard of three or more steps
sessionStorage / localStorage draftNo backend, instant, works for anonymous usersPer-device only; must exclude sensitive fields; quota and private-mode failuresAnonymous or low-stakes flows
Server-side draftCross-device resume, resume-by-email, no sensitive data on diskAutosave endpoint, cleanup job, and a bearer token if anonymous resume is neededAuthenticated, long or high-value flows

Where This Applies

A multi-step form is the clearest small demonstration of the single-owner rule from State Management Architecture: the moment step state is distributed across components, backward navigation loses data, and no amount of careful prop-passing fixes it. It is also where the transition-and-focus patterns from Accessibility Architecture matter most, because a step change is a content replacement that the browser does not announce on your behalf. And the split between client validation as feedback and server validation as enforcement is the boundary drawn in Security Architecture.

Within this track, a wizard is the container for other components in it - a date picker as a delivery-date field, an autocomplete for address lookup, a file uploader for document steps. The checkout variant shares its final step with Shopping Cart, where price recalculation and token handling on submission are the concerns that carry over.

Advertisement

Frequently Asked Questions

Why does per-step state cause data loss, and what does centralising it fix?

Because if each step component owns its own fields, that state is destroyed when the component unmounts. A user who fills step three, goes back to step two to correct something, and returns forward finds step three empty - the component was unmounted and remounted with fresh state. Keeping every step mounted and hidden avoids the unmount but replaces it with a different problem, since hidden inputs still participate in the accessibility tree and in form submission unless carefully managed. Centralising the data in one store above the step components means the store outlives every mount, so steps become pure views over shared state that can be freely mounted and unmounted. It also makes three other things possible that per-step state cannot express - validating a field on one step against a value from another, computing which step comes next from data collected earlier, and serialising the whole form for a draft.

When should validation run - per step, or at submit?

Both, for different reasons. Per-step validation gates progress so a user cannot carry an error five steps forward and discover it at the end, which is the single biggest cause of abandonment in long forms. But per-step validation alone is insufficient, because a value entered on an early step may only become invalid once a later step supplies context - a delivery date that is valid in general but not for the shipping method chosen two steps later. So each step validates its own fields on the attempt to advance, cross-field rules re-run whenever any of their inputs change rather than only on the step that owns them, and a final full-form validation runs before submission. And none of it is trusted - the server revalidates everything, because client validation is a user-experience feature and the server is the only actual enforcement.

How should each step map to a URL, and what does that mean for the back button?

Each step should be a real route, because a wizard with one URL breaks the browser back button in the most confusing possible way - pressing back exits the entire form instead of returning one step, losing everything. With a route per step, back and forward work as the user expects, a step is linkable and refreshable, and analytics can attribute drop-off to a specific step rather than a single page. The requirement this creates is that state must survive a route change and a page reload, which is exactly what the centralised store plus persistence provides. Guarding matters too - navigating directly to step four in a fresh session must redirect to the first incomplete step rather than rendering an empty step four, because the form's prerequisites were never satisfied.

Where should form progress be persisted, and what must never be stored?

For anonymous or low-stakes forms, sessionStorage or localStorage is enough and requires no backend - sessionStorage if abandoning the tab should discard the draft, localStorage if the user should find their progress days later. For authenticated flows, a server-side draft is better because it survives a device change and can be resumed on another machine, at the cost of a debounced autosave endpoint and a record to clean up. What must never be persisted client-side is anything you would not want read from disk by another script or another person using the device - full card numbers, CVVs, government identifiers, passwords. Those fields are excluded from the serialised draft explicitly, and a payment step should hold a tokenised reference from a payment provider rather than the raw values at all.

What does a screen reader user experience when a step changes, and what has to be done about it?

By default, almost nothing. Clicking Next replaces the visible fields, but focus stays on the Next button - which may no longer exist - and no announcement is made, so a screen reader user has no idea the content changed or what step they are on. Three things fix it. Move focus deliberately to the new step's heading, which is a non-interactive element made focusable with tabindex minus one so it can receive focus without becoming a tab stop, and announce it. Update the document title so the step is reflected in the browser and in the accessibility tree. And put validation errors in a live region as well as next to their fields, with focus moved to the first invalid field when advancing is blocked, so the reason for the block is both announced and reachable rather than being visible only to sighted users.