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.
| Question | Answer we assume | What 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.
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.
Decision How should a 40 field application be laid out?
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.
- 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". - 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.
- 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.
- 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.
- 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.
- 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.
Refresh resets step to 0, and the browser Back button leaves the wizard entirely because no history entries were created.
When a conditional step is removed, steps[step] now points at a different step than before, so the person can jump forward or backward by one without pressing anything. Indexes are not stable identities; step ids are.
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?
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.
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" },
];
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.
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?
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.
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.
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`
}
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.
| Level | Example | When it runs | Where the error shows |
|---|---|---|---|
| Field | Postcode 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. |
| Step | All required fields on this step are filled; "at least one driver". | On Continue. | Error summary at the top of the step, plus inline. |
| Cross-step | Leaving 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. |
| Server | Passport 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.
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.
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]]);
}
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.
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?
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 (
visibilitychangetohidden), withfetch(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 onbeforeunloadorunload: they don't fire reliably on mobile, andunloadhandlers 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.
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.
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?
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
| State | Meaning | Markup | Looks like |
|---|---|---|---|
| Done | Every step in it is complete. | A link, with visually hidden text "Completed:". | Check mark plus text. |
| Current | The step on screen is in this section. | aria-current="step" on the item. | Bold, with a marker. |
| Error | The server or the review step found problems here. | A link, with "Needs attention:" text. | Warning icon and text, never colour alone. |
| Upcoming | Reachable later, not started. | Plain text, no link. | Muted. |
| Not needed | Every step inside is off the path. | Plain text "Not needed". | Struck through or muted. |
<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>
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
submittingand ignores further Submit events. The button stays focusable witharia-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 theIdempotency-Keyheader onPOST /drafts/APP-7K2Q/submit, with the draftversion. 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.
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.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Wizard config | The product's steps: id, section, fields, schema, when, sensitive. | Plain data, so the engine, tests and analytics can all read it. |
| Path resolver | Pure 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 store | All values, per-field errors, server errors, upload states, draft version. | Survives step changes because it lives in the layout route, above the steps. |
| Lifecycle machine | Phases: editing, validating, submitting, done, failed. Guards call step schemas. | Stops double submits and racing transitions. |
| Router adapter | Maps 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 sync | Debounced saves, keepalive on hide, version conflicts, the local buffer. | Network and storage concerns stay out of the UI. |
| Shell UI | Header, step list, error summary, Back link, Continue, timeout dialog, focus handling. | Accessibility is built once and correct everywhere. |
| Step components | The product's fields and layout, rendered inside the shell. | The only part product teams write. |
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.
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.
| Data | Lives in | Why |
|---|---|---|
| Wizard config | Code bundle (static). | Same for everyone; versioned with the app. |
| Current step and draft id | URL. | Refresh, Back and resume links need it; not sensitive. |
| Values, errors, upload states | Wizard store in memory. | The working truth, read by every step. |
| Draft | Server, plus an IndexedDB buffer for non-sensitive unsent changes. | Durable, cross-device, access controlled, expires. |
| Path, active values, step states, derived values | Nowhere: computed. | Storing them would let them disagree with the values. |
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;
};
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?
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.
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} />;
}
The server API is small. Drafts are a resource; submit is an action on a draft.
| Call | Purpose | Notes |
|---|---|---|
POST /drafts | Start an application. | Returns { id, version }. |
GET /drafts/:id | Resume. | Returns values, version, status. 403 if not the owner. |
PATCH /drafts/:id | Autosave changed fields. | Body has version; 409 if stale. Can return step warnings. |
POST /drafts/:id/validate?step=claims | Optional server check for one step. | Used for lookups like a vehicle registration. |
POST /drafts/:id/submit | Submit once. | Idempotency-Key header. 201, 422 with field errors, or a status like payment_failed. |
{
"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." }
]
}
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
autocompletevalues (postal-code,bday) let the browser help too. - Group related inputs. A date of birth is three inputs in a
fieldsetwith alegend; 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).
| Event | Properties | Answers |
|---|---|---|
wizard_start | wizard, draft is new or resumed | How many begin, how many come back. |
step_view | step id, section, position on this person's path | Funnel: views of each step. |
step_complete | step id, seconds on step | Drop-off: views minus completes, per step. |
validation_error | step id, field name, error code (never the value) | Which question confuses people. |
save_exit and resume | step id | Where people pause to find documents. |
submit_result | status, error codes | Payment 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:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask 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 8 | Sketch the wireframe: shell, step list, h1, fields, Continue. | "The shell is ours; the panel is the product team's." |
| 8 to 18 | The 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 25 | One 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 32 | URL per step, guards with replace, saving and timeouts. | "Refresh and Back must never cost work." |
| 32 to 40 | Submit: idempotency key, partial failures, confirmation. Then the architecture diagram. | "A timeout is not a failure, so retry with the same key." |
| 40 to 45 | Accessibility 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?
Model it as one repeating step with an index in the URL (/others/2) and an array in the values (drivers.2.licence). After each driver, a question asks "Do you need to add another driver?"; Yes goes to the next index, No leaves the loop. A summary page lists drivers with Change and Remove links.
The path resolver expands the repeating step into one entry per driver, so the progress list, the guard and server error mapping (drivers.2.licence maps to others/2) all keep working without special cases.
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.
References
- GOV.UK Service Manual: Structuring forms Start with one thing per page, and why it helps with branching, errors and saving.
- GOV.UK design notes: One thing per page (2015) Why GDS moved from long pages to one question per page.
- Nielsen Norman Group: Wizards, definition and design recommendations When wizards help and when a single form is better.
- GOV.UK Design System: Error summary Placement, focus, links to fields, and the Error prefix on the title.
- GOV.UK Design System: Validation pattern When to validate, the Error title prefix, and moving focus to the summary.
- A List Apart: Inline validation in web forms (2009) Luke Wroblewski's study of when to show field errors.
- GOV.UK Design System: Check answers pattern The review step with Change links.
- GOV.UK Design System: Complete multiple tasks The task list hub for forms too long for one wizard.
- Understanding WCAG 2.2: Redundant Entry (3.3.7)
- Understanding WCAG 2.2: Timing Adjustable (2.2.1)
- React Hook Form: useForm The
shouldUnregisterdefault and how resolvers plug in. - WebKit blog: Full third-party cookie blocking and more The 7 day cap on script-writable storage in Safari.
- MDN: aria-current Including the step value for step indicators.
- IETF draft: The Idempotency-Key HTTP header field
Concepts behind it
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.