[Guide] Build a Multi-step Checkout (Route State Stepper) - implementation notes
The Multi-step Checkout guide pushes a clean idea: step transitions are driven by route state, not by rewriting URLs. Blur submission keeps the flow moving with frequent feedback, derived totals are stored to stabilize UI across steps, and redirect flows handle the inevitable payment/confirmation round trips. I'm curious what this feels like under real user behavior (back/forward, refresh, double submit).
How did you model the route-state stepper so back/forward replays correctly without leaking hidden UI state? Did you treat blur submission as the default boundary, and how did you prevent it from feeling "phantom"? How did you store derived totals so they stay consistent across steps and during partial failures? What evidence/log lines were most valuable when debugging "why did checkout move to the next step"?
Comments (20)
Back to latestRoute-state stepper was the biggest win for me because it makes the flow replayable.
If step is local component state, back/forward turns into a randomizer.
We logged step transitions as contract lines so movement wasn't mysterious:
txt
[tips] step=shipping -> step=billing reason=user:next
[tips] step=billing -> step=review reason=blur:submit valid=true
[tips] redirect from=/checkout to=/checkout/confirm reason=payment:handoff
And we rendered evidence: data-step, data-submit-lane, data-last-step-reason.
Counterpoint: blur submission can be hostile, especially on mobile where focus changes unexpectedly.
We used blur as the default but introduced a posture switch (submitPosture=blur|confirm) and logged it so it's explainable.
Yes. Blur isn't sacred; boundaries are.
If the posture is explicit and visible, you can choose the boundary that matches the device and the user.
Stored derived totals were necessary because shipping, tax, discounts, and fees touch multiple steps.
If each step computes totals, you get drift and users think the app is lying.
Long-form: checkouts are anxiety machines. Anything that looks inconsistent destroys trust.
Derived totals as a contract reduce anxiety because the number is stable across steps, and logs make it explainable when it changes.
The guide's approach is basically: stabilize outputs first, then worry about UX polish.
Redirect flows were easier once we treated them as part of the contract and logged them. If the app jumps to confirmation without a log line, users think it glitched:
txt
[tips] redirectChain=/checkout -> /checkout/pay -> /checkout/confirm reason=payment:successCounterpoint: route state stepper can make sharing links difficult if the URL doesn't encode enough context.
We put only the step in route state (replayable), and we keep share-worthy context (cart id) in URL or stable identity.
We treated submit as an idempotent intent too. If blur triggers twice, it's safe. And we log dedup explicitly:
txt
[tips] intent=submit:shipping key=sub_19 lane=ok reason=dedupDocs tie-in: this is routing + forms posture merged: explicit boundaries, explicit state, visible evidence.
Related: Routing and Navigation and Forms and Validation (Best Practices).
Long-form counterpoint: steppers can become a way to hide complexity rather than manage it.
If your steps have hidden prerequisites, you'll end up with brittle transitions. We made prerequisites explicit and rendered them as evidence (blocked reasons).
We rendered data-step-blocked-reason and it saved us.
Most "checkout stuck" bugs were actually prerequisites not met (address missing, payment method invalid). Evidence made that obvious.
The stepper contract made tests nicer too: tests can assert step transitions without guessing timing.
If step is a visible contract, E2E becomes deterministic.
Counterpoint: derived totals can make teams complacent about pricing correctness.
We still validate totals server-side and render a lane for verification. Otherwise totals become a UI illusion.
We logged total changes with reasons so users and support could understand changes:
txt
[tips] derive=totals reason=shipping:methodChange total=42.19Long-form: the guide is basically an argument that checkout should be a state machine you can narrate.
If you can't narrate it with logs and evidence, users won't trust it and support won't be able to help.
If you implement this, start by defining your step vocabulary and the evidence keys per step.
Once those exist, the rest is just wiring.
We also treated back/forward as first-class: back is a step transition, not a special case.
Once you log back transitions, you stop blaming browsers for your app's hidden state.
We made the stepper state typed and derived a compact evidence string from it.
Typed stepper prevented "unknown step" bugs and made logs consistent.
Counterpoint: stepper transitions can feel too rigid if users want to jump around.
We allowed jumping but logged jump reasons and validated prerequisites explicitly, so it still felt coherent.
The biggest win was reducing ad-hoc useEffect glue for checkout flows.
Once step transitions are explicit, a lot of effect glue disappears because the contract tells you what should happen.