Accessibility · Forms and Errors

Multi-Step Form State and What Survives a Step

What a wizard loses between steps when each step is a fresh document, how a step boundary changes validation, and how to make back navigation non-destructive.

A single-page form keeps its state in the DOM. A wizard does not. Each step is, in the common implementation, a fresh document, either a server round trip or a client route change that replaces the tree, and everything the previous step knew about the user is gone unless it was stored somewhere the next step can read. The visible symptom of forgetting is an empty field on return; the audible symptom is a step boundary at which focus resets to the top of the document and the user has to relocate themselves.

State persistence and focus continuity are separate problems with separate mechanisms, and both get harder at every boundary.

What is lost at a boundary

Four things disappear when a step is replaced rather than hidden.

The values in the inputs, unless they were submitted and re-rendered. The validity state of each control, which is derived from the value and therefore recomputed from whatever the new step’s markup declares. The dirty or touched flags a script was tracking, if the script’s memory is per-step. And focus, which returns to the document’s default because the focused element no longer exists.

The third item is the one that changes validation behaviour most and is least often considered. A wizard that validates on blur and marks fields as touched will, on returning to a step, treat every field as untouched, so nothing is flagged until the user interacts again, which is lenient and usually acceptable. The opposite failure, where the new step’s markup carries error messages rendered from the last submission, shows errors on fields the user has not yet seen, which is the more confusing of the two.

The fourth item is the one that fails most consistently. A step change is a navigation, and navigation puts focus at the top of the new document. A user who was working in the last field of step two and moved to step three arrives at the page banner, and must traverse the site header, the navigation, and the step indicator before reaching step three’s first field.

Storing values across steps

The mechanism depends on whether the boundary is a document load or a client-side route change.

A document load discards client memory. The values must travel in the request, as form fields in the submission that produced the next step, or be stored in something that outlives the document: a cookie, sessionStorage, or server-side state keyed by a session identifier. Server-side state is the only option that survives a new tab, a reload, and a device change, and it is the only option that does not put the user’s data into a place another script on the origin can read.

sessionStorage survives a reload of the same tab and is cleared when the tab closes. Its capacity is on the order of a few megabytes, which is enough for form data and not enough to be a general store. It is per-origin and per-tab, so a form opened in a second tab starts empty, and a wizard opened in a new tab from a link in the first has none of the first tab’s progress. That is the correct default and a frequent support question.

Client-side routing keeps the document alive, so a store outside the step component, such as a module-level object, a context, or a state library, is sufficient and no serialisation is required. The risk here is the inverse: the store outlives the wizard. A user who completes a form, navigates elsewhere in the single-page application, and starts a new one can find the previous submission’s values prefilled, because nothing cleared the store on success.

Validation at a step boundary

A wizard validates per step rather than per form, and that changes what the native API can do. checkValidity() on a <form> evaluates only the controls inside that form. When each step is its own <form>, validating step three cannot see step two’s fields, which is fine if step two was already validated and its results stored, and wrong if step two’s fields can be edited later from a review page, because the stored result is now stale.

The architecture that avoids the problem keeps one form element across all steps and hides the inactive steps with the hidden attribute. Hiding does not remove a control from validation: a control inside a display: none subtree is still a candidate, and checkValidity() on the form still evaluates it. What bars a control from validation is being disabled, so the single-form design disables the controls in inactive steps and enables them as each step is revealed.

That has a second consequence that is easy to miss. A disabled control is not submitted, so a wizard that leaves earlier steps disabled at the moment of final submission sends only the current step’s values. The usual repair is to re-enable every step just before submission, which restores the values to the request but also restores them to validation, so the full check happens on the whole form at once. The alternative, separate forms and a server-side merge, needs the merge to be correct, and a step that fails to submit because its form was not the one posted is a data loss bug that presents as a validation bug.

Focus at the new step

The new step should receive focus, and the target should be the step’s heading rather than its first field. Focusing the first field moves the user straight into input and skips the step title and any instruction above it, so the user does not know which step they are on. A heading with tabindex="-1" is focused programmatically and announces the step name; the user’s next Tab reaches the first field.

<h2 id="step-3-heading" tabindex="-1">Step 3 of 4: Payment</h2>

The same tabindex="-1" rule as the error summary applies, and the same sequencing hazard: the heading must exist before focus() is called on it. In a client-side router that mounts the new step asynchronously, the focus call belongs in the mount hook rather than immediately after the navigation call.

Progress indicators need to be part of what is announced. “Step 3 of 4” in the heading gives position and total in one string. A visual progress bar with no text equivalent conveys nothing, and a bar whose aria-valuenow changes without a live region announces nothing either. The heading is the cheaper and more reliable carrier.

Back navigation and its contract

The back button is where wizards lose data most often, and the loss is usually silent. A step that re-renders from an empty store presents the user with blank fields for values they entered two steps ago, and the natural conclusion is that the form forgot.

The contract that holds is that a completed step’s values are available when the step is revisited, and that revisiting does not invalidate later steps unless the change actually affects them. Both halves need explicit handling.

The first half is a storage question, already covered: the values must be somewhere the step can read. The second half is a dependency question. A wizard for a booking has steps for dates, then for a room, then for extras. Changing the dates on return to step one may invalidate the room availability chosen on step two, and silently keeping the room produces a submission that fails at the end, after the user has done all the work. The rule that avoids this is that a change to a step invalidates the steps that depend on it, and the invalidation is announced rather than performed silently: a message on the dependent step saying what has to be chosen again.

Where no dependency exists, re-validating everything on return is unnecessary work that can only produce spurious failures. A change to a delivery address does not invalidate a payment card.

The review step and irreversible actions

The final review step exists because of the error-prevention requirement for consequential submissions: a user must be able to check, correct, and confirm before the action becomes irreversible. The review page is therefore the one place in a wizard where all values must be simultaneously visible and editable, and where each value links back to the step that owns it rather than to the field directly, because returning from a field edit to the review page requires the wizard to know where the user came from.

The cheapest implementation of that return path is a query parameter or a route segment recording the origin, and it is worth stating as a requirement rather than a detail: a review page whose edit links drop the user at the start of the wizard has converted one navigation into four.

Confirmation is also the place where the whole-form validation runs for the first time. A wizard that validates each step on advance has already established that every field was valid at the moment its step was left; the review page’s full check catches the cases where that is no longer true. A failure there is not a field error but a change of state, and the summary should say which step needs revisiting rather than which field failed, since the field is not on the current page. The safeguards that belong on that page are the subject of Preventing Irreversible Errors in Consequential Submissions. The summary mechanics in Error Summaries and Focus Placement apply, with the links pointing at steps rather than at fields.

Session lifetime

Everything above assumes the state survives long enough to be used. It often does not, and the failure is the worst kind: the user fills five steps, walks away, returns, and the session has expired. The submission is rejected and the form is empty.

Two mitigations are structural rather than cosmetic. The state should be recoverable after expiry: a saved draft keyed by something the user can present again, so that an expired session costs a re-authentication rather than a re-entry. And the expiry should be announced before it happens, with an extension available, rather than discovered at submission. Neither is an accessibility feature in origin; both are error-prevention measures whose absence falls hardest on users who take longer to complete a form, which includes anyone using a screen reader, switch input, or speech input at a pace the form’s timer was not designed around.

A timeout warning that is announced only visually is not a warning for those users. It needs the same treatment as an error: a live region, a focus target, and enough time to act on it.

References

  1. HTML Living Standard: Constraint validation — WHATWG
  2. Understanding Success Criterion 3.3.4: Error Prevention (Legal, Financial, Data) — W3C WAI
  3. Understanding Success Criterion 2.4.3: Focus Order — W3C WAI