cf.completefrontend
My progress
SYSTEM DESIGN · #34

Design a multi-step form wizard

A reusable wizard for long forms like checkout, onboarding, insurance quotes and visa applications: steps that appear or vanish based on earlier answers, validation at the field, step and server level, a URL per step, drafts that survive a closed tab, and a final submit that happens exactly once.

The question

Design a multi-step form component for our platform. Teams will use it for customer onboarding, an insurance quote, and a visa application with 4 to 10 steps, where some steps only appear depending on earlier answers.

People must be able to go back, refresh, leave and come back tomorrow, and fix errors the server finds after they press Submit. Walk me through the step logic, where form data lives, validation, the URL, saving progress, accessibility, and the API teams will use.

Real-life examples: The UK government's apply for a passport and visa services, Insurance quote forms that ask about your car, then your drivers, then your cover, Onboarding in Stripe, Shopify and Slack, Loan and mortgage applications, The checkout in Amazon or a travel booking site

Requirements: one wizard, three very different forms

A multi-step form, often called a wizard, splits one long form into a series of pages. You fill in one page, press Continue, and the next one appears, while a progress indicator shows where you are. At the end you check your answers and submit once. Checkout in an online shop, the sign-up flow of a SaaS product, a car insurance quote and a visa application all work this way.

The interviewer asked for a component that three teams will share, and the three forms are very different: onboarding has 4 short steps and no money involved. The insurance quote has 7 steps, and the "Other drivers" step only exists if you say someone else drives the car. The visa form has 10 steps, takes 30 to 60 minutes, asks for a passport scan, and people often stop halfway to find a document. A design that only fits the onboarding flow will fall apart on the visa form.

Questions to ask

Each question below changes a real decision later in the guide. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
How many steps, and do they change?4 to 10 steps. Some appear or vanish based on earlier answers.Steps form a graph with conditions, not a fixed array. The progress indicator must handle a changing total.
How long does a person spend?2 minutes for onboarding, up to an hour for the visa form, often across days.Drafts saved on the server, resume links, and session timeout warnings.
Is the data sensitive?Yes: passport numbers, dates of birth, health questions on the insurance form.No sensitive values in localStorage or in the URL. Drafts live on the server behind login.
Who validates?The client for fast feedback, the server as the final judge.Server errors must map back to a step and a field.
What happens on submit?Money may be charged (insurance) or a fee paid (visa).Idempotent submit, double-click protection, and a confirmation page with a reference number.
Are there file uploads?The visa form needs a passport photo and scans.Uploads start inside a step and finish before submit; we reuse the file uploader component.
Which devices and networks?Half the traffic is mobile, some on slow 3G in other countries.Small steps, little JavaScript per step, and saving that survives a dropped connection.
Do we need to meet an accessibility standard?Yes, WCAG 2.2 AA. Public sector services in many countries must meet WCAG AA by law.Focus moves on every step change, an error summary, no disabled buttons, no asking twice.

Functional requirements

  • Show one step at a time, with Continue and Back, and a step list or progress bar.
  • Show or skip steps based on earlier answers, and recompute the path when an answer changes.
  • Validate each step's fields on Continue, rules that span steps on the review step, and accept errors from the server after submit.
  • Give each step its own URL so the browser Back button, refresh and bookmarks work.
  • Save progress as a draft, let people leave and resume later, and warn before a session times out.
  • End with a review page that has a Change link per answer, then submit once and show a confirmation with a reference number.
  • Let product teams describe a wizard as configuration plus their own field components.

Non-functional requirements

  • Fast steps: moving to the next step should feel instant, under 100 ms when no server check is needed. Each step's code is small enough to load on demand.
  • No lost work: a crash, a refresh, a closed tab or a lost connection costs at most the last few seconds of typing.
  • Exactly one submission: a double click, a retry after a timeout, or a second tab never creates two applications or two charges.
  • Accessible: keyboard and screen reader users always know which step they are on and what went wrong.
  • Private: sensitive answers never sit unencrypted on a shared computer and never leak into analytics or URLs.
  • Measurable: the team can see where people give up, step by step.

Out of scope

A drag and drop form builder for non-developers is a separate problem (see Design a form builder). Payment card entry is its own component with its own security rules, so we treat it as one opaque step. Co-editing a form by two people at once is rare for wizards and we leave it out.

Wireframe of the insurance wizard on the Drivers step. A header reads Get a car insurance quote, with Saved 10:42 and a Save and exit link. On the left, a section list: Car is done, Drivers is current, then History, Cover and Review. The main panel has a Back link, the h1 Does anyone else drive the car, where focus lands, two radio options Yes and No, and a Continue button.example.com/quote/APP-7K2Q/driverGet a car insurance quoteSaved 10:42, Save and exitCar: doneDriversHistoryCoverReviewBack linkDoes anyone else drive the car?h1, tabindex=-1: focus lands hereYesNofields: the product team's stepContinuenever disabled
The frame is the wizard's: header, section list, Back link, focus handling and Continue. Only the question and its fields come from the product team's step component.

The wireframe above is the shell every team gets: a header with the service name and a Save and exit link, a section list on wide screens (one line such as "Section 2 of 5: Drivers" on phones), one heading that asks the step's question, the fields, and a Continue button with a Back link. Everything inside the white panel is the product team's; everything around it is the wizard's.

Check your understanding · Exercise 1

Which answers to the scope questions change the design the most? Select all that apply.

Split into steps, or one long page?

Before designing a wizard, ask whether the form should be one. A wizard adds clicks, page changes and a progress indicator to maintain. A single page with sections lets people scan everything, jump around, and see all their answers at once. Both are right for some forms.

The UK Government Digital Service (GDS) changed its form guidance in July 2015. The earlier version had weighed three layouts: one question per page, all questions on one page, and one page with earlier questions collapsed. After more experience building services for the general public, GDS wrote on its design notes blog that teams should start with the first. Its reasons: less confident users find it easier, it works well on phones, and it handles errors, branches, loops and saving progress better.

The GOV.UK Service Manual still says to start with one thing per page: one piece of information, one decision, or one question. User research then tells you when pages can be merged, for example in internal services for government staff who repeat and switch between tasks quickly.

Two layouts compared. Left, one long page: sections Your car, Your drivers and Your cover stacked with their fields, and one Submit at the bottom. Right, one thing per page: a small page reading Step 2 of 5, Does anyone else drive, with Yes and No and a Continue button. Below it, pages car, drivers and cover in a row joined by arrows.one long pageYour carYour driversYour coverSubmitone thing per pageStep 2 of 5Does anyone else drive?YesNoContinuecardriverscovereach page: save, measure, branch
The long page shows everything at once, so branches have to show and hide blocks in place. Separate pages turn each page into a save point, a measurement point, and a place a branch can skip entirely.

Decision How should a 40 field application be laid out?

One long page with sections

All fields render at once, grouped under h2 headings.

One Submit at the bottom; errors are shown all together.

Pros

  • People can scan the whole form and see how long it is.
  • No navigation code, no URLs per step, one validation pass.
  • Fast for experts who know the answers and want to tab through.

Cons

  • Branching shows and hides blocks in place, which shifts the layout and confuses screen reader users.
  • On a phone, 40 fields is a long scroll and the error summary is far from most fields.
  • Saving progress and measuring drop-off are coarse: you only know the page was abandoned.

One thing per page

Each page asks one question or a few tightly related fields (a postcode lookup and its result).

Continue validates and moves on; branches skip pages entirely.

Pros

  • Each page is small, focused and fast on phones.
  • Errors are few and close to their fields.
  • Natural save points and per-question analytics.
  • Branches are invisible: people never see questions that don't apply to them.

Cons

  • Many page loads; a 40 question form can feel endless without a good progress cue.
  • People can't see what is coming, so a review page is mandatory.
  • Experts find it slow.

Grouped steps (4 to 10 pages)

Related questions share a page: "Your car", "Your drivers", "Your cover".

Each group is a step in a step list.

Pros

  • A middle path: fewer page changes, still small pages.
  • Step names give a mental map of the whole task.
  • Each step is still a save and measurement point.

Cons

  • Pages with 8 to 10 fields bring back some long page problems.
  • Deciding the grouping takes research, not guesswork.

What we pick: Our component must support all three, because the product decides. Its default is grouped steps with one topic each, and teams can make a step hold one question for the visa form. The engine treats "one question" and "a group of fields" the same way: a step is a page with fields, a condition and a schema. For long applications that people finish over several sessions, GOV.UK's Complete multiple tasks pattern (a task list page with a status for each task) is the better outer shape, and our wizard can run one task inside it.

The research point to remember is not "wizards are better". It is that splitting makes branching, error recovery and saving easier, which are exactly the hard parts of the next chapter. The cost is that people lose the overview, so a good wizard gives it back through the step list and the review page. Nielsen Norman Group gives similar advice: use wizards for novice users and infrequent processes, and offer people who repeat a task often a faster alternative.

Check your understanding · Exercise 2

Which forms are good fits for one thing per page? Select all that apply.

What makes a wizard hard

A wizard looks like a few forms with Next buttons. Most first attempts are exactly that: an array of step components, an index in useState, and index + 1 on Next. That works for the 4 step onboarding flow and breaks on the other two. Six properties of the problem drive every later decision.

  1. It is a graph, not a list. On the insurance form, answering "Yes, my partner drives too" adds an "Other drivers" step. Answering "No claims in 5 years" removes the "Claims history" step. Next is not index + 1; it is "the first step after this one whose condition is true".
  2. The data outlives every page. Answers from step 2 decide whether step 5 exists, the review step shows all of them, and a draft must survive a closed tab and a new device. The state belongs to the whole wizard, not to a step component that unmounts on Continue.
  3. Validation happens at four levels, in two places. A field checks its own format, a step checks all its fields on Continue, some rules span steps (the leaving date must be after the arrival date entered on an earlier step), and the server has the final say after submit. Each error must land on the right step and field.
  4. There are two Back buttons. The wizard has its own Back link and the browser has one too. People use both, and both must do the same safe thing. Refresh must not drop you on step 1.
  5. It ends with one action that can't be undone. Submitting an insurance application charges a card. A double click, a slow network with a retry, or a second tab must not create two of anything.
  6. Page changes are invisible to assistive technology. In a single page app, swapping step 3 for step 4 does not trigger the page load announcement a screen reader gives for a real navigation. Without work, a blind user presses Continue and hears nothing.

The chapters follow these six: the step graph, the form store, validation, the URL, saving progress, the progress indicator, and submit. Architecture, data model and API come after, once the hard parts are clear.

Think first

A wizard keeps const [step, setStep] = useState(0) and renders steps[step]. Name two things that go wrong on the insurance form.

The wizard as a state machine: a step graph, not a list

A state machine is a model with a fixed set of states, the events each state accepts, and the state each event leads to. (The State machines lesson covers the idea in depth.) A wizard is a natural fit: each step is a state, Continue and Back are events, and a guard is a check that must pass before a transition happens, here "this step's fields are valid".

The twist is conditional steps. Take the insurance quote: Car, Main driver, Other drivers (only if "Does anyone else drive?" is Yes), Claims history (only if there were claims), Cover, Extras (only for comprehensive cover), Review. That is 7 possible steps, but a single driver with no claims and third party cover sees 4. The set of steps depends on the answers, and the answers can change.

Decision How do we model the steps and moves between them?

An array and an index

steps[i] renders; Next does i + 1, Back does i - 1.

Conditions are if checks that skip an index here and there.

Pros

  • Ten lines of code. Easy to read for a fixed, short flow.

Cons

  • Skipping logic spreads across Next and Back handlers and gets out of sync: Back lands on a step Next skipped.
  • Indexes shift when a step appears, so the person jumps.
  • No single place answers "which steps exist right now?"

A full statechart (XState style)

Every step is a state with its own NEXT and BACK transitions and guards.

Branches are written as guarded transitions: NEXT: [{ target: 'otherDrivers', guard: 'hasOtherDrivers' }, { target: 'claims' }].

Pros

  • Every move is explicit and can be visualised and tested.
  • Handles side effects (saving, server checks) as part of the model.

Cons

  • Back must mirror every forward branch, so each condition is written twice.
  • Product teams must learn statecharts to add a step.
  • The machine says how to move, but the progress indicator also needs the whole path ahead, which a transition table doesn't give directly.

A derived path plus a small machine

Each step declares an optional when(values) condition.

A pure function computes the path: all steps whose condition is true, in order.

Next and Back move along the path. A small machine handles the wizard's own phases: editing, validating, submitting, done.

Pros

  • Conditions are written once. Next, Back, the step list and the review page all read the same path.
  • The path is recomputed from values, so it can never disagree with the answers.
  • Easy for product teams: a step is config with a condition.

Cons

  • Loops ("add another driver" repeated) need an extra idea: a repeating step with an index.
  • Very irregular flows that jump far ahead are awkward.

What we pick: We pick the derived path. The step graph is described as data, the path is a pure function of the answers, and only the wizard's lifecycle (editing, validating, submitting, done, failed) is a real state machine with guards. Saving is not a phase: draft sync runs in the background whatever the phase. This keeps the most error-prone logic, "what comes next", in one function that's easy to unit test with a table of answers.

The insurance step graph and the lifecycle machine. Seven steps in order: Car, Driver, Others, Claims, Cover, Extras, Review. Others, Claims and Extras are dashed because they are conditional: Others when anyoneElse is yes, Claims when hadClaims is yes, Extras when cover is comprehensive. Arrows below skip from Driver to Cover and from Cover to Review otherwise. Underneath, the lifecycle: editing goes to validating on NEXT; validating goes to the next step on the path when the guard passes, and back to editing with errors when it fails.CarDriverOthersClaimsCoverExtrasReviewanyoneElse = yeshadClaims = yescover = comprehensiveotherwiseotherwiseeditingvalidatingnext on pathNEXTvalidguard fails: errors
The conditions belong to the steps, not to the arrows, so they are written once. The path is whatever survives the conditions. The part of the lifecycle drawn here only knows editing and validating; it asks the path where Next goes.
ts
type Step = {
  id: string;
  title: string;
  fields: string[];
  when?: (v: Values) => boolean;
};

export function getPath(steps: Step[], v: Values): Step[] {
  return steps.filter((s) => !s.when || s.when(v));
}

export function nextStep(steps: Step[], v: Values, current: string) {
  const path = getPath(steps, v);
  const i = path.findIndex((s) => s.id === current);
  return path[i + 1]?.id ?? "review";
}

// insurance config (fields lists shortened)
const steps: Step[] = [
  { id: "car", title: "Your car", fields: ["reg"] },
  { id: "driver", title: "Main driver", fields: ["dob", "anyoneElse", "hadClaims"] },
  { id: "others", title: "Other drivers", fields: ["drivers"],
    when: (v) => v.anyoneElse === "yes" },
  { id: "claims", title: "Claims", fields: ["claims"],
    when: (v) => v.hadClaims === "yes" },
  { id: "cover", title: "Cover", fields: ["cover"] },
  { id: "extras", title: "Extras", fields: ["breakdown"],
    when: (v) => v.cover === "comprehensive" },
];
The path is derived, never stored. Next and Back read it.

Which steps can a person visit?

Each step on the path is in one of three states for navigation: complete (its schema passes with the current values), current, or locked. A step is reachable if every step before it on the path is complete. This one rule powers the step list links, the URL guard in a later chapter, and the review page. It is also derived: change an answer on step 2 so a new step appears at position 3, and steps 4 onward become locked until step 3 is filled in, with no extra code.

The lifecycle machine is small. In editing, the event NEXT moves to validating. If the guard (the step schema) fails, it returns to editing with errors. If it passes, the router moves to the next step on the path and draft sync saves in the background, so Continue never waits on the network.

On the review step, SUBMIT moves to submitting, then done or failed. A reducer with a switch on the phase is enough; XState is a fine choice if the team already uses it.

Step graph with 4 steps on the path. Current step: Cover. Steps done: Car, Driver. Values kept but not submitted: none. Section 4 of 5.stepsCardoneDriverdoneOthersskippedClaimsskippedCovernowExtrasskippedReviewlockedsections in the progress listCardoneDriversdoneHistorynot neededCovercurrentReviewupcomingSection 4 of 5: Coverpath: 4 of 7 stepssubmitted: 3 steps' valueskept: none
You are on Cover. Change an answer, or press Continue and Back.

Check your understanding · Exercise 3

A person on the insurance form reaches Cover, then goes back to Main driver and changes "Does anyone else drive?" from No to Yes. What should happen? Select all that apply.

Form state across steps

Every step needs values from other steps: the path needs "Does anyone else drive?" from step 2, the review page shows everything, and the visa form pre-fills the "Address in the UK" step with the hotel from the travel step. So where do the values live? The Form state lesson covers form state in general; here the question is scope.

Decision Where do the wizard's answers live?

Per step state, handed up on Continue

Each step component owns its fields in local state.

On Continue it passes its values to the parent, which merges them.

Pros

  • Steps are independent and easy to build in isolation.

Cons

  • Values typed on a step and then left with Back are lost, because the step unmounts.
  • Cross-step rules and conditions only see values after Continue, never live.
  • Two copies of the truth: the step's and the parent's.

One store for the whole wizard

One values object for all fields, owned by the wizard (a reducer, Zustand, or one React Hook Form instance).

Steps render fields bound to that store by name.

Pros

  • Nothing is lost on Back; conditions and the review page read live values.
  • One object to save as a draft and to submit.
  • Cross-step validation is one schema over one object.

Cons

  • Every keystroke updates a shared store, so field components must subscribe narrowly to avoid re-rendering the whole wizard.
  • Must decide what to do with values of steps that are no longer on the path.

The server is the store

Each Continue posts the step to the server, and each step loads its values from the server.

Pros

  • Resume on any device for free.
  • The server validates every step as it goes.

Cons

  • A network round trip on every step change, often 300 ms or more on a mobile network, and the form stops working offline.
  • Live conditions within a step still need client state.

What we pick: One client store for the whole wizard, seeded from the server draft when the page loads and saved back to the server in the background. The server copy is for durability and resume; the client copy is the working truth. With React Hook Form this is one useForm above all steps, and its default shouldUnregister: false keeps values of fields that unmount when a step changes, which is exactly what a wizard needs.

The wizard store inside the layout route. Three step components on the left: Driver step, mounted; Others step, skipped but its values kept; Car step, unmounted. All write to the Wizard store, which holds all values. The store feeds resolve, which gives the path and active values to the review page, submit, price and conditions. The store also feeds Draft sync, which sends all values to the server draft.Wizard layout routeDriver stepmountedOthers stepskipped: keptCar stepunmountedWizard storeall values, errorsresolve()path, active valuesactive onlyreview, submitprice, conditionsDraft syncall valuesServer draft
Steps come and go; the store stays because it lives in the layout route. Two different outputs leave it: the draft gets every value, while review, submit and price only get what resolve() marks active.

Values on skipped steps: keep or clear?

Someone says Yes to "Does anyone else drive?", adds their partner Sam with a date of birth and licence number, then goes back and says No. The Other drivers step leaves the path. What happens to Sam?

  • Clear immediately: simple and private, but if the No was a mis-click, the person retypes everything. That breaks the spirit of WCAG 3.3.7 Redundant Entry, which says information already given in the same process should be filled in or offered again.
  • Keep in the store, ignore everywhere else: Sam stays in memory and in the draft, so switching back to Yes restores him. But the review page, the submit payload, conditions, derived values and the price all use only values from steps on the current path.

We keep and ignore, and the rule that makes it safe is a function that returns only the active values: the fields of steps on the current path. There is one trap: conditions can read fields from steps that are themselves conditional. If Extras depends on cover, and cover lived on a hidden step, a stale cover would wrongly show Extras. So the path is built step by step, and each condition sees only the values of steps already accepted onto the path.

ts
export function resolve(steps: Step[], all: Values) {
  const path: Step[] = [];
  const active: Values = {};
  for (const step of steps) {
    if (step.when && !step.when(active)) continue;
    path.push(step);
    for (const name of step.fields) {
      if (name in all) active[name] = all[name];
    }
  }
  return { path, active }; // submit and review use `active`
}
Build the path in order; each condition only sees active values so far.

For sensitive values there is one exception: a stale passport number or health answer should not sit in a draft for 30 days. Steps marked sensitive have their values removed when the step leaves the path, after a short undo window.

Derived values such as the driver's age, the trip length, or the quote price are never stored. They are computed from active values when needed. The price is the server's to compute, so the client asks for it with the active values and shows a loading state; storing a copy invites a stale price on the review page.

Check your understanding · Exercise 4

Sam's details stay in the store after Other drivers leaves the path. Which places must ignore them? Select all that apply.

Validation: field, step, cross-step and server

A wizard validates at four levels. Each level has its own timing, its own way of showing errors, and its own place in the code. The Input validation lesson covers the security side (never trust the client); this chapter is about the experience and the plumbing.

LevelExampleWhen it runsWhere the error shows
FieldPostcode format, email shape, a date that exists.On Continue by default, or when the person leaves the field (blur) if the field opts in. Once it shows an error, on every change.Inline, under the field.
StepAll required fields on this step are filled; "at least one driver".On Continue.Error summary at the top of the step, plus inline.
Cross-stepLeaving date on the Stay step after the arrival date on the Trip step; main driver at least 17 on the cover start date.On Continue of the later step, and again on the review page.Error summary on that step or on review, linking to the field that must change.
ServerPassport number already used in another application; address not found; card declined.On background step checks and after Submit.Mapped back to the step and field; the review page lists them.

When to show a field error

Showing "Invalid email" after the first letter is hostile, but sources disagree on what comes next. GOV.UK's validation pattern says not to validate when a person moves away from a field, and to wait until they press Continue. Luke Wroblewski's 2009 study for A List Apart found that inline errors cut mistakes by 22 percent and completion time by 42 percent on its test form, against errors shown only after submit. Among its inline variants, checking after the person left the field worked best.

Our wizard defaults to GOV.UK's rule, because each step is short, so feedback on Continue is never far from the typing. A team can opt a field into blur checks by listing it in the step's blurFields, for a format rule on a longer step such as a postcode. Either way, once a field shows an error it re-checks on every change, so the error disappears the moment the value is fixed. Empty required fields always wait for Continue, because tabbing past a field is not a mistake yet.

The error summary and where focus goes

When Continue fails, GOV.UK's error summary pattern is the best tested answer we know of. Below the Back link and above the h1, a box titled "There is a problem" lists every error as a link, and each link moves focus to its field. Keyboard focus moves to the summary itself, so a screen reader reads the errors right away and a sighted keyboard user starts there.

The page title also gets the prefix "Error: ", so screen readers announce the problem as early as possible. Each field gets an inline message joined to it with aria-describedby, and aria-invalid="true". GOV.UK requires both the summary and the inline messages, worded the same, so people can match them.

The message text matters as much as the plumbing: say what to do ("Enter a date of birth in the past"), not what went wrong in code terms ("Invalid input"). This is also what WCAG asks for: success criterion 3.3.1 Error Identification (level A) says the field in error is identified and the error described in text, and 3.3.3 Error Suggestion (level AA) says a fix is suggested when one is known.

Sequence diagram of pressing Continue, between the User, the Wizard, the step Schema and the Server. The user presses Continue. The wizard runs safeParse on the step values. The schema returns 2 issues. The wizard shows the error summary and moves focus to it. The user fixes the fields and presses Continue again, and this time the schema passes. The wizard pushes the URL of the next step and moves focus to its h1 at once. In the background it sends PATCH with version 7, and the server replies 200 with version 8.UserWizardSchemaServerpresses ContinuesafeParse(step)2 issuessummary gets focusfixes, ContinuesafeParse: okpush URL, focus h1PATCH, version 7200, version 8
The failure path never touches the network or the URL: the guard stops the transition and focus goes to the summary. The success path moves on at once and saves in the background, so Continue never waits on the network; if the save fails, draft sync retries.

One schema per step, one for the whole wizard

Each step declares a schema (we use Zod; Yup or Valibot work the same way). The step guard runs that step's schema plus any cross-step rule whose fields are all on steps already done. The review page runs the wizard schema: all active step schemas merged, plus every cross-step rule. The server runs the same rules again with its own copy, since anything in the browser can be changed.

With React Hook Form and one useForm for the whole wizard, a resolver such as zodResolver(wizardSchema) validates every value it is given. The step guard then calls trigger(stepFieldNames), which returns whether those fields are valid and updates errors only for them, so errors on later steps stay hidden until the person gets there.

ts
import { z } from "zod";

const trip = z.object({ arrival: z.coerce.date() });
const stay = z.object({
  hotelPostcode: z.string().min(5),
  leaving: z.coerce.date(),
});

// built from the active path only
const wizardSchema = trip.extend(stay.shape).refine(
  (v) => v.leaving > v.arrival,
  { path: ["leaving"], message: "Enter a leaving date after your arrival date" },
);

const result = wizardSchema.safeParse(activeValues);
if (!result.success) {
  // each issue has a path like ["leaving"]; fieldStep maps it to a step id
  const byStep = groupBy(result.error.issues, (i) => fieldStep[i.path[0]]);
}
Step schemas plus a cross-step rule that points at the right field.

Mapping server errors back to steps

The server answers a failed submit with field paths, not step ids, because steps are a UI idea. The wizard builds a fieldStep map from the config once (drivers.0.licence belongs to step others). On a 422 response it stores the errors, shows the review page with a summary grouped by step ("Other drivers: Enter the licence number exactly as it appears on the card"), and each link opens that step with focus on the field.

An error whose field the client doesn't know, such as a manual review, shows in the summary with no link and a plain next action. When the person fixes the field, its server error clears on change; the server checks again on the next submit.

Postcode
Leaving date
Screen /visa/APP-7K2Q/stay with heading Where are you staying?. No error summary. Focus is on the the h1. Steps with errors: none./visa/APP-7K2Q/stayWhere are you staying?Postcode: (empty)Leaving date: (empty)focus:the h1page title:plainPassportTripStayReview
You are on the Stay step. Pick values, then leave the postcode field or press Continue.

Check your understanding · Exercise 5

A person presses Continue on a step with two empty required fields. What should happen? Select all that apply.

Saving progress, resuming, and session timeouts

The visa form takes up to an hour and asks for documents people have to go and find. If closing the tab loses the work, many won't come back. Saving is not a feature on top of the wizard; for long forms it is the wizard.

Decision Where is a draft saved?

In the browser (localStorage or IndexedDB)

Write the values object locally on every change; read it back on load.

Pros

  • Instant, works offline, no backend work.
  • Fine for non-sensitive forms like a newsletter preferences wizard.

Cons

  • Doesn't follow the person to their phone.
  • Sensitive data sits unencrypted on what might be a shared library computer, and any XSS bug can read it.
  • Safari's tracking prevention deletes a site's script-written storage, IndexedDB and localStorage included, after 7 days of Safari use with no interaction on that site.

On the server

PATCH /drafts/:id with changed values, debounced, plus a save on every Continue.

Pros

  • Resume on any device, survives a lost laptop.
  • Access control, encryption at rest and expiry are the server's job.
  • The server can pre-validate steps as they are saved.

Cons

  • Needs a signed-in person or a secure resume link.
  • A network call per save; offline typing is lost unless buffered.

Server drafts with a small local buffer

The server holds the draft. Unsent changes for non-sensitive fields wait in IndexedDB until the save succeeds, then are deleted.

Pros

  • Survives a dropped connection mid-step.
  • Sensitive fields still never touch local storage.

Cons

  • Two places to reconcile on load: the local buffer wins only if it is newer than the server version.

What we pick: Server drafts for the insurance and visa forms, with the local buffer for fields not marked sensitive. The onboarding wizard, which holds a name and a team size, can use IndexedDB alone. The component exposes a storage adapter so each product picks; the IndexedDB lesson covers the local side.

When to save

  • On Continue, always. It is the natural checkpoint, and the server can run its step checks at the same time.
  • 2 seconds after the last change inside a step (debounced), so a long free-text answer isn't lost to a crash.
  • When the page is hidden (visibilitychange to hidden), with fetch(url, { keepalive: true }) so the request can finish even if the tab is closing. Keepalive bodies are capped at 64 KiB in total, another reason to send only changed fields. Don't rely on beforeunload or unload: they don't fire reliably on mobile, and unload handlers can stop the page from entering the back/forward cache.

Each save sends only changed fields and the draft's version. The server rejects a save whose version is older than its own with 409 Conflict. That catches the same draft open in two tabs: the second tab shows "This application was changed in another window" and reloads the newer copy instead of silently overwriting it. A quiet "Saved at 10:42" next to Save and exit reassures people without announcing every save to screen readers.

Sequence diagram of autosave with a failure, between the Field, Draft sync, IndexedDB and the Server. After a change and 2 quiet seconds, Draft sync buffers the unsent change in IndexedDB, then sends PATCH with version 8. The request fails because the device is offline. Draft sync tells the field area: not saved yet, retrying. It retries after 4 seconds. The server replies 200 with version 9, and Draft sync deletes the buffer.FieldDraft syncIndexedDBServerchange, 2 s quietbuffer changePATCH, version 8offline: failsNot saved, retryingretry after 4 s200, version 9delete buffer
The local buffer only holds fields not marked sensitive, and only until the server confirms. The person sees an honest status instead of a silent failure, and the retry waits longer each time instead of hammering a dead network.

Save and exit, and coming back

Save and exit saves now, then shows a page saying how long the draft is kept (we assume 30 days) and how to return: sign in and open "Your applications", or use the link we email. A resume link opens /apply/visa/APP-7K2Q and the wizard routes to the first incomplete step. The link itself is never enough to see the answers: it leads to sign in, because emails get forwarded.

Session timeouts and WCAG 2.2.1

Banks and governments sign people out after, say, 30 minutes idle. A person reading the guidance for a document question can easily pass that. WCAG 2.2 success criterion 2.2.1 Timing Adjustable (level A) says that when there is a time limit, people must be able to turn it off, adjust it, or be warned before it ends and given at least 20 seconds to extend it with a simple action, at least ten times. Limits over 20 hours are exempt.

Our wizard shows a modal dialog 2 minutes before the limit: "You'll be signed out in 2 minutes. Your answers are saved." with a "Stay signed in" button that refreshes the session. Focus moves into the dialog so screen reader users hear it.

If time runs out anyway, the wizard saves first, then signs out, and after signing back in, the person lands on the same step with every answer. That also meets the stricter AAA criterion 2.2.5 Re-authenticating: after signing in again, people continue without losing data.

Timeline of a 30 minute idle session limit. From 0 to 10 minutes the person types. From 10 to 28 minutes they are idle, reading guidance. At 28 minutes a warning dialog offers Stay signed in with one press. At 30 minutes, if nobody answered, the wizard saves the draft first, then signs out, and after signing in again the person resumes on the same step.session limit: 30 min idlewarned 2 min aheadStay signed in: one presstypingidle, readingwarningsign out0 to 10 min10 to 28 min28 min30 mintimed out anyway: save, sign out, resume on the same step
The warning gives far more than the 20 seconds WCAG 2.2.1 asks for, and extending takes one press. Because the draft saves before sign-out, even a missed warning costs a sign-in, not an hour of answers.

Check your understanding · Exercise 7

The visa wizard autosaves. Which choices are sound? Select all that apply.

The progress indicator

Splitting a form takes away the overview. The progress indicator gives some of it back: how far along am I, how much is left, and can I jump back to fix something? It sounds like decoration, but conditional steps make it a real design problem.

Decision What does the progress indicator show?

A counter: "Step 3 of 7"

One line of text above the heading.

Pros

  • Tiny, works on any screen width.

Cons

  • When a conditional step appears, "3 of 6" becomes "3 of 7" and people feel the goalposts moved.
  • No way to jump back.

A percentage bar

A bar filled to completed steps divided by total steps.

Pros

  • Glanceable.

Cons

  • Jumps backward when a step is added.
  • Says nothing about what is left. Steps vary in length, so 50 percent can mean 5 or 40 minutes left.

A list of named sections

Each step belongs to a section: Car, Drivers, History, Cover, Review.

The list shows sections with a state each; conditional steps live inside a section.

Pros

  • The number of sections stays fixed while steps come and go inside them.
  • Names tell people what is ahead ("Cover" means choosing a policy).
  • Done sections are links, so people can go back and fix them.

Cons

  • Needs width: a side list on desktop, collapsed to "Section 2 of 5: Drivers" with a Show all toggle on phones.

What we pick: Named sections. Conditional steps nest inside a section, so the visible count stays at 5 on the insurance form whatever the answers are. When a whole section is conditional, it shows with the text "Not needed" rather than disappearing, so the list doesn't reshuffle. On phones, the list collapses to one line plus a disclosure button.

Five states per section

StateMeaningMarkupLooks like
DoneEvery step in it is complete.A link, with visually hidden text "Completed:".Check mark plus text.
CurrentThe step on screen is in this section.aria-current="step" on the item.Bold, with a marker.
ErrorThe server or the review step found problems here.A link, with "Needs attention:" text.Warning icon and text, never colour alone.
UpcomingReachable later, not started.Plain text, no link.Muted.
Not neededEvery step inside is off the path.Plain text "Not needed".Struck through or muted.
The section list in five states. Car is done and is a link with hidden text Completed. Drivers is current and has aria-current step. History is not needed and is plain text. Cover needs attention after a server error and is a link with hidden text Needs attention. Review is upcoming and is plain text. A side panel shows that inside Drivers there are now two steps, Main driver and Other drivers, while the count of sections stays at 5.Cara: Completed: CarDriversaria-current="step"Historyspan: (not needed)Covera: Needs attentionReviewspan: upcominginside Drivers1. Main driver2. Other driverssections stay at 5
Every state is written in text, not only drawn, so it reaches screen reader users. Only done and error sections are links. Conditional steps live inside a section, so adding Other drivers changes nothing in the list itself.
tsx
<nav aria-label="Application progress">
  <ol>
    {sections.map((s) => (
      <li key={s.id} aria-current={s.state === "current" ? "step" : undefined}>
        {s.state === "done" || s.state === "error" ? (
          <a href={stepUrl(s.firstStep)}>
            <span className="sr-only">
              {s.state === "done" ? "Completed: " : "Needs attention: "}
            </span>
            {s.title}
          </a>
        ) : (
          <span>{s.title}{s.state === "skipped" ? " (not needed)" : ""}</span>
        )}
      </li>
    ))}
  </ol>
</nav>
The step list is an ordered list in a labelled nav; state comes from the derived path.

Upcoming sections are not links even if they are reachable, because jumping ahead skips the order the form was designed for. The one exception is the review step after a Change link, where everything before it is done.

Check your understanding · Exercise 8

The insurance form adds an Other drivers step when someone says Yes. Which progress designs avoid moving the goalposts? Select all that apply.

Review, submit once, and confirm

The review step

The review step lists every active answer grouped by section, each row with a Change link (GOV.UK calls this the check answers pattern). It is where cross-step rules run one last time. Each Change link carries a visually hidden suffix ("Change date of birth"), because twelve links all named "Change" are useless in a screen reader's links list.

It also meets a WCAG requirement. Success criterion 3.3.4 Error Prevention (level AA) covers submissions with legal or financial effect: they must be reversible, checked for errors, or reviewed and confirmed before they are final. A review step is the usual way to meet it, and our insurance and visa forms need it.

File uploads inside steps

The visa form's passport scan uses the shared uploader from Design a file uploader. The wizard's job is only the glue: the upload starts as soon as the file is chosen, and the store keeps the server's fileId, never the file bytes, which don't fit in a JSON draft.

The step guard lets people continue while an upload is still running, so a slow connection doesn't hold them on one page, but it stops them if the upload failed. The review step's guard waits until every upload is done and lists any that failed, with links back.

Exactly one submission

The insurance submit charges a card. Three things can cause two submissions: a double click, a retry after a request that timed out but actually succeeded, and the same draft submitted from two tabs. Double clicks are common: the GOV.UK Notify team found people receiving invitations twice because senders double clicked Send. The fix has a client half and a server half, the same one the travel booking and Amazon checkout guides use for payments.

  • Client: on Submit, the lifecycle machine moves to submitting and ignores further Submit events. The button stays focusable with aria-disabled="true" and the text "Submitting...", so people see why another click does nothing.
  • Key: when Submit is first pressed, the wizard creates one key for this attempt with crypto.randomUUID() and sends it as the Idempotency-Key header on POST /drafts/APP-7K2Q/submit, with the draft version. The server stores the key with its response, and any repeat with the same key gets that stored response instead of running again.
  • Retry: if the request times out, the client retries with the same key, with backoff. A repeat that arrives while the first is still running gets 409 Conflict, the code the IETF Idempotency-Key draft suggests, and the client waits and asks again.
  • Two tabs: each tab makes its own key, so keys can't catch this case. The draft can: the server allows one successful submission per draft and answers a second with the existing reference number.
Sequence diagram of submitting, between the User, the Wizard, the Server and Payments. The user double clicks Submit; the wizard ignores the second click. The wizard sends POST submit with idempotency key K1. The server charges the card through Payments, which replies ok. The response to the wizard is lost to a timeout at 15 seconds. The wizard retries with the same key K1. The server finds the stored result and replies 201 with reference APP-7K2Q, without charging again. The wizard replaces history with the confirmation page.UserWizardServerPaymentsSubmit, double clickPOST, key K1charge cardoktimeout at 15 sretry, same key K1201: APP-7K2Qconfirmation
The charge happened once, even though the wizard never heard back the first time. Reusing K1 is what lets the server recognise the retry and return the stored answer instead of running the submission again.

Partial failures

Submission is often several things: create the application, charge the fee, send the email. The server should own that order and report a clear state, not a bare 500. If the application is created but the card is declined, the response says payment_failed with the application id, and the wizard shows the payment step again with the error, keeping every answer. Paying with another card is a new attempt with a new key, because the old key would only replay the stored decline.

If validation fails (422), errors map to steps as in the validation chapter. If the server is down (503), the draft is safe; the page says so and offers Try again with the same key.

State machine of submission. Review goes to submitting on SUBMIT; in submitting, extra clicks are ignored. Submitting goes to done on 201, showing the confirmation page. It goes to invalid on 422, which maps errors to steps and returns to review after fixes. It goes to payment failed when the card is declined, which goes back to the payment step and keeps every answer; paying with a new card is a new attempt with a new key. It goes to unavailable on 503 or a timeout, which offers Try again with the same key.reviewsubmittingclicks ignoreddoneconfirmation pageSUBMIT201invaliderrors to stepspayment failedback to paymentunavailableTry again422declined503 or timeoutfixkeeps every answernew card: new keyretry: same key
Each failure has its own state because each needs a different next action. A bare error toast for all three would send people back to the start, or worse, make them submit twice.

On success, the wizard replaces history with the confirmation page: a green panel with the reference number ("APP-7K2Q", short, without easily confused characters like 0 and O), what happens next and when, and that an email was sent. The draft becomes read-only on the server, and every step URL for it redirects to the confirmation.

Check your understanding · Exercise 9

The submit request times out after 15 seconds on a train. What should the wizard do? Select all that apply.

Architecture, data model and API

With the hard parts decided, the pieces fall out. The key boundary is between what every wizard shares (the engine and the shell) and what each product owns (its steps, fields, schemas and conditions). Product teams should never write navigation, saving or error summary code.

PartResponsibilityWhy the boundary is here
Wizard configThe product's steps: id, section, fields, schema, when, sensitive.Plain data, so the engine, tests and analytics can all read it.
Path resolverPure function: config plus values gives the path, active values, and step states.The riskiest logic lives in one place with no React, easy to unit test.
Wizard storeAll values, per-field errors, server errors, upload states, draft version.Survives step changes because it lives in the layout route, above the steps.
Lifecycle machinePhases: editing, validating, submitting, done, failed. Guards call step schemas.Stops double submits and racing transitions.
Router adapterMaps step ids to URLs, runs the reachability guard, pushes or replaces.Wraps Next.js, React Router or another router so the engine doesn't depend on one.
Draft syncDebounced saves, keepalive on hide, version conflicts, the local buffer.Network and storage concerns stay out of the UI.
Shell UIHeader, step list, error summary, Back link, Continue, timeout dialog, focus handling.Accessibility is built once and correct everywhere.
Step componentsThe product's fields and layout, rendered inside the shell.The only part product teams write.
Architecture of the wizard. The product team owns the Wizard config with steps, schemas and conditions, and the Step views with their fields. The shared engine holds the Path resolver, the Lifecycle machine, the Wizard store, the Router adapter and Draft sync. The Shell draws the section list, error summary and focus. Config feeds the resolver; step views write to the store; the store feeds the resolver, the shell and draft sync; the lifecycle asks the resolver for the next step; draft sync talks to the Drafts API; the router adapter updates the Browser URL.Product teamWizard configsteps, schemas, whenStep viewsthe team's fieldsShell UIShelllist, summary, focusWizard enginePath resolverpath, active valuesLifecycleediting to doneWizard storevalues, errorsRouter adapterguard, push, replaceDraft syncPATCH, local bufferDrafts APIBrowser URLPATCH /draftsURL
Everything a product team writes sits in the top-left box. The engine knows the whole graph from config alone, which is why the URL guard, review page and server error mapping work without rendering any step.

Data moves in one direction for reads and one for writes. On load, the layout route fetches the draft (GET /drafts/APP-7K2Q), merges any newer local buffer, and seeds the store. The resolver turns values into the path, the router adapter checks the URL's step against it, and the shell renders the step. On a change, the field writes to the store; the store notifies the subscribed field and the resolver; draft sync schedules a save.

On Continue, the machine runs the guard. If it passes, the router adapter pushes the next step's URL and draft sync saves in the background, so moving on never waits for the network.

Data flow of the wizard. Reads: the Server draft sends values and version 7 to Load and merge, which seeds the Wizard store along with any newer local buffer. The store's values go to resolve, which gives the path and active values to the Step view. Writes: the Step view sends each change to the store; the store, after 2 quiet seconds, hands changes to Draft sync, which sends PATCH with changed fields to the Server. On Continue, the router pushes the next step to the Browser URL.Server draftLoad + mergeWizard storeGET values, v7seed + newer bufferStep viewresolve()Draft syncvaluespath, activechange2 s quietBrowser URLServerPATCH /drafts/:idContinue: router pushchanged fields, v7
One loop: the server seeds the store, the store drives what renders, and every change goes back to the store first. Only Draft sync talks to the network, and only the router touches the URL.

Each step's component is loaded with a dynamic import() when its route is first visited and prefetched when the step before it renders. A phone on a slow 3G connection then downloads one step's date picker or uploader when it needs it, instead of the code for all 10 visa steps up front.

Data model: what lives where

The wizard has five kinds of data, each with a home chosen by how long it lives and who may see it. Getting the homes right is most of the privacy story.

DataLives inWhy
Wizard configCode bundle (static).Same for everyone; versioned with the app.
Current step and draft idURL.Refresh, Back and resume links need it; not sensitive.
Values, errors, upload statesWizard store in memory.The working truth, read by every step.
DraftServer, plus an IndexedDB buffer for non-sensitive unsent changes.Durable, cross-device, access controlled, expires.
Path, active values, step states, derived valuesNowhere: computed.Storing them would let them disagree with the values.
ts
type StepConfig = {
  id: string;            // stable, used in the URL
  section: string;       // groups steps in the progress list
  fields: string[];      // from the schema's keys
  schema: ZodType;
  when?: (active: Values) => boolean;
  sensitive?: boolean;   // never buffered locally; cleared when skipped
  blurFields?: string[]; // also checked on blur
};

type WizardState = {
  draftId: string;
  version: number;       // for 409 conflict checks
  values: Values;        // all values, including skipped steps
  fieldErrors: Record<string, string>;
  serverErrors: { field?: string; code: string; message: string }[];
  uploads: Record<string, { fileId?: string; status: "uploading" | "done" | "failed" }>;
  phase: "editing" | "validating" | "submitting" | "done" | "failed";
  lastSavedAt?: string;
};
The client's types. Notice what is not here: no stored path, no stored price.

Field names are dotted paths (drivers.0.licence), the same paths the server uses in its errors and the schema uses in its issues. One naming scheme across store, schema and API is what makes server errors land on the right field without a translation table per form.

Check your understanding · Exercise 10

Which of these should be computed rather than stored? Select all that apply.

The component API and the server API

Three teams will use this component, and more will follow. The Component API design lesson's advice applies: make the common case short, make the hard case possible, and don't let the API leak internals. The question is how a team describes its steps.

Decision How do product teams declare a wizard?

Children: a Step component per step

<Wizard> with <Step id="car" when={...}> children, each wrapping fields.

Pros

  • Reads like the page. Familiar React shape.

Cons

  • The engine must render or walk children to know the path, the sections and the field names, so the review page and URL guard depend on rendering.
  • Conditions hidden in JSX are hard to test without a DOM.

Config only: fields generated from a schema

A JSON or schema description of every field; the wizard renders inputs itself.

Pros

  • Non-developers could edit it; great for a form builder.

Cons

  • Every custom layout or widget needs an escape hatch, and soon the config grows its own language.

Config for structure, components for content

An array of step configs (id, section, fields, schema, when) plus a component per step.

A useWizardField(name) hook binds inputs to the store.

Pros

  • The engine knows the whole graph without rendering anything.
  • Teams keep full control of layout and use the design system's inputs.
  • Conditions and schemas are plain functions, testable with a table of values.

Cons

  • Field names appear in two places (the step schema and its component), so a dev warning must catch mismatches.

What we pick: Config for structure, components for content. It is the only option where the path, the review page, server error mapping and analytics work without rendering steps. Each step's field list comes from its schema's keys, and a dev-mode check warns when a step renders a field its schema doesn't know.

tsx
const steps = defineSteps([
  { id: "car", section: "Car", schema: carSchema, component: CarStep },
  { id: "driver", section: "Drivers", schema: driverSchema, component: DriverStep },
  { id: "others", section: "Drivers", schema: othersSchema, component: OthersStep,
    when: (v) => v.anyoneElse === "yes", sensitive: true },
  { id: "cover", section: "Cover", schema: coverSchema, component: CoverStep },
]);

<Wizard
  name="Get a car insurance quote"
  steps={steps}
  draft={serverDraft(api, draftId)}       // or localDraft("onboarding")
  crossStep={insuranceRules}              // runs on review
  onSubmit={(active, key) => api.submit(draftId, active, key)}
  onStepView={(id) => track("wizard_step_view", { wizard: "quote", step: id })}
/>

function DriverStep() {
  const dob = useWizardField("dob");
  return <DateInput label="What is your date of birth?" {...dob} />;
}
How the insurance team uses it.

The server API is small. Drafts are a resource; submit is an action on a draft.

CallPurposeNotes
POST /draftsStart an application.Returns { id, version }.
GET /drafts/:idResume.Returns values, version, status. 403 if not the owner.
PATCH /drafts/:idAutosave changed fields.Body has version; 409 if stale. Can return step warnings.
POST /drafts/:id/validate?step=claimsOptional server check for one step.Used for lookups like a vehicle registration.
POST /drafts/:id/submitSubmit once.Idempotency-Key header. 201, 422 with field errors, or a status like payment_failed.
json
{
  "status": "invalid",
  "errors": [
    { "field": "drivers.0.licence", "code": "licence_not_found",
      "message": "Enter the licence number exactly as it appears on the card" },
    { "field": "coverStart", "code": "too_late",
      "message": "Cover must start within 30 days of today" },
    { "code": "manual_review",
      "message": "We need to check some details. We will email you within 2 working days." }
  ]
}
A 422 from submit. Field paths match store and schema names.

Check your understanding · Exercise 11

Why does the config-for-structure API beat a children-only API for this wizard? Select all that apply.

Accessibility, performance, privacy and analytics

Focus and announcing the step change

In a multi-page app, screen readers announce each new page as it loads; in our single page wizard, nothing happens unless we make it happen (the Focus management and Screen readers lessons go deeper). After every step change, the document title updates first ("Your drivers: section 2 of 5: Get a car insurance quote"). Then focus moves to the step's h1, which has tabindex="-1" so it can receive focus without joining the tab order.

A screen reader reads the focused heading, so the person hears the new question. Keyboard users start from the top of the new content instead of a Continue button that no longer exists.

Don't add a live region announcement on top of the focus move: people hear the same thing twice. One exception to the order: if validation fails, focus goes to the error summary, not the h1. And with prefers-reduced-motion: reduce, the slide between steps becomes an instant swap.

  • No auto-advance. Picking a radio button must not jump to the next step. WCAG 3.2.2 On Input says changing a control must not change context unexpectedly, and people often pick the wrong radio first.
  • Don't ask twice. WCAG 2.2 success criterion 3.3.7 Redundant Entry (level A) says information the person already entered in the same process must be filled in again for them or offered to select. The visa form's "Address in the UK" offers the hotel from the travel step; the billing address step has "Same as home address". Correct autocomplete values (postal-code, bday) let the browser help too.
  • Group related inputs. A date of birth is three inputs in a fieldset with a legend; a yes or no question is a radio group with the question as the legend, which on one-question pages can also be the h1.
  • Visible step names. The step list's states are text, not just colours or icons.

Privacy and security

Values never appear in URLs, logs or analytics. Steps marked sensitive skip the local buffer and are left out of error reports sent to tools like Sentry. The server re-runs every schema, checks draft ownership on every call, and expires drafts. Session cookies are HttpOnly, so an XSS bug can't read the session, and state-changing calls carry CSRF protection.

Measuring drop-off per step

Splitting the form pays off here: each step is a measurement point. The wizard emits events, and the product sends them to its analytics tool (the Analytics tracking lesson covers batching and consent).

EventPropertiesAnswers
wizard_startwizard, draft is new or resumedHow many begin, how many come back.
step_viewstep id, section, position on this person's pathFunnel: views of each step.
step_completestep id, seconds on stepDrop-off: views minus completes, per step.
validation_errorstep id, field name, error code (never the value)Which question confuses people.
save_exit and resumestep idWhere people pause to find documents.
submit_resultstatus, error codesPayment failures vs validation failures.

Because paths differ, compute the funnel per step id, not per position: "step 3" is Other drivers for one person and Claims for another. A step with a high error rate on one field usually needs better question wording, not better code.

Check your understanding · Exercise 12

A screen reader user presses Continue and the next step renders. What should they experience? Select all that apply.

Running it in the interview

A wizard question sounds easy, which is the trap: candidates draw three boxes with Next buttons and run out of things to say. The strong answer names the hard parts early and spends the time on them. A plan for 45 minutes:

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: steps, conditions, length, sensitivity, money, uploads."Conditional steps and a one hour visa form change the design a lot, so let me confirm those."
5 to 8Sketch the wireframe: shell, step list, h1, fields, Continue."The shell is ours; the panel is the product team's."
8 to 18The step graph: config with when, derived path, reachability, the small lifecycle machine."Next is the first step after this one whose condition is true, never index plus one."
18 to 25One store, active values, keep or clear skipped values, validation levels and the error summary."Skipped values stay in the draft but never reach submit, review or price."
25 to 32URL per step, guards with replace, saving and timeouts."Refresh and Back must never cost work."
32 to 40Submit: idempotency key, partial failures, confirmation. Then the architecture diagram."A timeout is not a failure, so retry with the same key."
40 to 45Accessibility and analytics; take follow-ups."Focus to the h1 on every step change, to the summary on errors."

Draw the step graph first, with one conditional step, and keep it on the board: almost every later answer points back to it. Interviewers often probe loops, two tabs, or what happens after a failed submit; the question below is the most common.

Think first

The insurance form must allow up to 5 other drivers, each with the same questions. How does the step graph handle a loop?

Check your understanding · Exercise 13

You have 2 minutes left. Which points best show senior judgement? Select all that apply.

Summary

  • Split long forms into steps when it helps people: GOV.UK found one thing per page works best for unfamiliar tasks and makes branches, errors and saving easier. Give the overview back with a step list and a review page.
  • Steps form a graph. Store the answers, derive the path with a pure function, and let Next, Back, the progress list, the URL guard and the review page all read it.
  • Keep one store for the whole wizard. Keep values of skipped steps for an easy undo, but submit, review, price and conditions use only active values. Clear sensitive ones.
  • Validate at four levels: field, step on Continue, cross-step on review, server after submit. Wait for Continue unless research says otherwise, show an error summary that takes focus, and map server field paths back to steps.
  • Give each step a URL with a stable id. Continue pushes, guards replace, and after submit every step URL redirects to the confirmation.
  • Save drafts on the server, on Continue, after a pause, and when the page is hidden. Handle version conflicts. Warn before session timeouts and let people extend.
  • Submit exactly once: ignore repeat clicks, send an idempotency key, retry with the same key, and report partial failures as states.
  • On every step change, update the title and move focus to the h1. Never disable Continue, never auto-advance, never ask for the same information twice.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next