Users press the browser’s Back button to go to the previous wizard step; if the wizard keeps its step only in component state, Back leaves the page, the unsaved-changes prompt fires, and a user who dismisses it loses every answer.
The step logic itself belongs to the machine described in multi-step form state machines. History is a second, external source of navigation that the machine has to accept events from and report its position to — without letting the URL bypass validation or the branching rules.
Context and prerequisites
Three expectations from users collide here:
- Back goes to the previous step, not the previous page. People use the browser button, the mouse’s back key and the swipe gesture far more than an in-page “Back” link.
- Reload keeps the step. Refreshing on step 3 should show step 3, which means the step is in the URL (or in restored state).
- Links cannot skip ahead. A bookmarked or shared
?step=reviewmust not open the review step for a user who has not completed the steps before it.
The pattern: the URL is a request to be on a step; the machine decides whether that request is allowed; the URL is then corrected to wherever the machine actually is.
The core pattern: a two-way adapter with the machine as authority
export interface StepMachine {
current(): string;
canVisit(step: string): boolean; // every step before it complete and on the path
goTo(step: string): void; // only called after canVisit
subscribe(fn: (step: string) => void): () => void;
}
const STEP_PARAM = "step";
export function syncWizardWithHistory(machine: StepMachine): () => void {
const readStep = () => new URL(location.href).searchParams.get(STEP_PARAM);
const urlFor = (step: string) => {
const u = new URL(location.href);
u.searchParams.set(STEP_PARAM, step);
return u.toString();
};
// 1. On load: the URL is a REQUEST. Honour it only if the machine allows it,
// otherwise correct the URL in place (replace, not push).
const requested = readStep();
if (requested && requested !== machine.current() && machine.canVisit(requested)) {
machine.goTo(requested);
}
history.replaceState({ wizardStep: machine.current() }, "", urlFor(machine.current()));
// 2. Machine moved (via in-page buttons): add a history entry — unless the
// move came FROM history, in which case the entry already exists.
let fromPopstate = false;
const unsubscribe = machine.subscribe((step) => {
if (fromPopstate) return;
if (history.state?.wizardStep === step) return;
history.pushState({ wizardStep: step }, "", urlFor(step));
});
// 3. Back/Forward: translate into machine navigation, respecting guards.
const onPop = (e: PopStateEvent) => {
const target = (e.state?.wizardStep as string | undefined) ?? readStep();
if (!target) return;
fromPopstate = true;
try {
if (machine.canVisit(target)) machine.goTo(target);
// Forward to a step that is no longer on the path (answers changed):
// stay put and rewrite this entry so the URL tells the truth.
else history.replaceState({ wizardStep: machine.current() }, "", urlFor(machine.current()));
} finally {
fromPopstate = false;
}
};
addEventListener("popstate", onPop);
return () => { unsubscribe(); removeEventListener("popstate", onPop); };
}
In a framework router the same shape applies: treat the route parameter as the request, validate it against the machine in a route guard or loader, and redirect with replace when it is not allowed.
Step-by-step walkthrough
- Put the step in the URL. A query parameter or path segment makes reload and Back work with no extra storage.
- Treat the URL as a request, not a command. On load and on
popstate, ask the machine whether the step can be visited; it can only if every earlier step on the current path is complete. - Correct with
replaceState. When the request is refused, rewrite the current entry to the machine’s actual step so the address bar never lies and no junk entries accumulate. - Push on in-page navigation only. Next and Back buttons move the machine; the subscriber pushes an entry. A move that came from
popstatemust not push, or Back would add an entry and trap the user. - Store the step in
history.stateas well. It survives even if another script rewrites the query string, and it tells you which entry you are on. - Combine with the unsaved-changes guard carefully. Moving between steps is not leaving the form; only the entry before the first step should trigger the guard from warning before leaving a form with unsaved changes.
Failure modes and edge cases
1. Back after submit
After a successful submit, Back returns to the review step and a second click on Submit creates a duplicate. When the machine reaches done, replaceState the review entry with the confirmation URL, so Back from the confirmation goes to the page before the wizard. Pair it with the server-side protections in handling double submit and idempotency.
2. Forward to a step that is no longer on the path
The user reached the tax step, went back to account, switched to personal. The Forward button now points at step=tax, which is not on their path. canVisit refuses, and the entry is rewritten — which is the correct outcome, even if it means Forward appears to do nothing once.
3. Validation on Back
Do not validate the current step before moving back. Users go back to check or change an earlier answer; blocking them because the current step is incomplete is hostile. Keep the partial answers in state instead.
4. Scroll and focus restoration
Browsers restore scroll position on popstate, often to the wrong place because the step’s content changed. Set history.scrollRestoration = "manual" while the wizard is mounted, scroll to the top, and move focus to the step heading.
5. Server-rendered steps
If each step is a separate server-rendered page, history works by default but the server must enforce the same canVisit rule — redirecting a direct request for step 4 to the first incomplete step — or deep links skip validation.
Verification checklist
Frequently Asked Questions
Should every step get its own history entry?
Usually yes, because it is what users expect from Back. The exception is a transient step such as a “checking your details” interstitial, which should use replaceState so Back skips over it.
Can I use the Navigation API instead of history.pushState?
The Navigation API’s navigate event lets you intercept Back and Forward in one place and is available in Chromium-based browsers and, more recently, others. The logic is the same — treat the destination as a request and let the machine decide. Keep a popstate fallback for browsers without it.
Does the step need to be in the URL if I persist progress anyway?
Persistence restores answers; the URL is what makes Back and Forward work. You can keep the step out of the query string by storing it only in history.state, but a visible parameter also makes support conversations easier (“which step are you on?”).