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=review must 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.

Back button handled by the machine When the user completes the address step, the machine moves to review and the adapter pushes a history entry for the review step. When the user presses the browser Back button, the browser fires popstate with the address step. The adapter translates it into a BACK event, the machine moves to address, and no new history entry is pushed because the history already points there. Browser history Adapter Step machine NEXT (address valid) now at review pushState step=review popstate step=address (Back pressed) BACK now at address; no push

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

  1. Put the step in the URL. A query parameter or path segment makes reload and Back work with no extra storage.
  2. 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.
  3. 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.
  4. Push on in-page navigation only. Next and Back buttons move the machine; the subscriber pushes an entry. A move that came from popstate must not push, or Back would add an entry and trap the user.
  5. Store the step in history.state as well. It survives even if another script rewrites the query string, and it tells you which entry you are on.
  6. 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.
Handling a requested step from the URL If the requested step is already the current step, do nothing. If the machine allows the step because every earlier step on the path is complete, move the machine there without pushing a new history entry. Otherwise keep the machine where it is and replace the current history entry with the machine's actual step, so the URL matches what is shown. Requested step is current? Do nothing yes no machine.canVisit(step)? Move the machine yes no replaceState to actual step The URL is corrected, not obeyed.

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.

History operations and when to use each Use pushState when the user moves to a different step with an in-page Next or Back button. Use replaceState on initial load to normalise the URL, when a requested step is refused, and when the wizard completes to replace review with the confirmation. Make no history change when the move came from popstate, because the entry already exists. Operation Use when pushState in-page Next or Back moved the machine to a different step replaceState initial load; a refused request; completion replaces review with confirmation no change the move came from popstate: the entry already exists

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?”).


Related

← Multi-Step Form State Machines