React Multi-Step Forms: 1 of 5 Form Components Here Needs One
Most tutorials assume a step-index useState pattern by default. This codebase's one real example is two states confirming an irreversible choice, not more fields.
Most React multi-step form tutorials assume the pattern by default: a step index in state, one component rendering whichever step is current. This codebase has 5 form-shaped client components, and exactly 1 of them uses that pattern. The other 4 stay single-step because their field count never earns the complexity, and the one that does need a step isn't gating validation — it's confirming an irreversible choice, and the server re-checks everything regardless of which step the client thinks it's on.
Three things people call a "multi-step form"
| Pattern | What changes between steps | URL | Used here |
|---|---|---|---|
| Single-step form | Nothing — one submit | One URL | ContactForm, NewsletterForm, SettingsForm |
| Client-state wizard | A step index in useState, all fields held in one component | One URL, never changes | RedemptionPicker — 2 states, not really a wizard |
| Route-based flow | Each "step" is its own page, its own URL, its own server render | Changes per step | The signup → verify → login journey |
The SERP for this keyword is wall-to-wall the second row — a useState step index, a formData object threaded through <Step1 />/<Step2 />/<Step3 />, sometimes a state-machine library. That pattern is real and sometimes correct. It's just not what most of this codebase's own multi-part flows turned out to be.
The one component that has a step, and what the step actually is
RedemptionPicker lets a buyer redeem a purchased slot for one catalog product. Picking a template reveals a confirmation before the actual submit, because the choice is final — there's no swap-later path:
// src/components/molecules/RedemptionPicker.tsx
const [selectedValue, setSelectedValue] = useState(options[0]?.value ?? "");
const [confirming, setConfirming] = useState(false);
// ...
{!confirming ? (
<button type="button" onClick={() => setConfirming(true)}>
Redeem…
</button>
) : (
<div className="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
<p>
<strong>This choice is final</strong> — {selected?.label} can't be
swapped for another template later.
</p>
<button type="submit" disabled={pending}>
{pending ? "Redeeming…" : "Confirm redemption"}
</button>
<button type="button" onClick={() => setConfirming(false)}>
Cancel
</button>
</div>
)}
Two useState calls, no step-index enum, no formData object passed between components — the select's value and a boolean are the entire "wizard" state. That's the honest shape of a two-stage flow when the second stage adds zero new fields: it isn't collecting more information, it's making the buyer look at the choice twice before it becomes permanent.
The step is not the security boundary — the server action is
The tell that confirming is a UX affordance, not a gate, is what happens on the server regardless of it. redeemSlot() re-derives and re-checks everything the client already "confirmed," because a forged request can hit the action directly and skip the client entirely:
// src/lib/actions/redemption.ts
export async function redeemSlot(_prev, formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, message: "Please sign in to redeem." };
if (!user.email_confirmed_at) {
return { ok: false, message: "Verify your email before redeeming a template." };
}
const parsed = validateRedemptionInput(formData);
if (!parsed.ok) return { ok: false, message: GENERIC_ERROR };
const product = getProduct(parsed.value.productSlug);
// Redemption spends a paid, non-reversible slot — must never "buy" a free
// product, even though the dashboard UI already only offers premium ones.
if (product?.pricing !== "premium") return { ok: false, message: GENERIC_ERROR };
// ...ownership-scoped read, then the atomic write, both filtered to user.id
}
Four checks — signed in, email verified, input shape, pricing tier — run every time this action fires, whether the client showed one step or ten. The confirming boolean the user saw is entirely absent from this function; it never crosses the wire, because useActionState only sends the <form>'s actual fields. If a multi-step form's later steps exist to gate access to something (a paid tier, an admin action, a destructive operation), the step itself enforces nothing — this codebase's one example is a clean illustration of the rule precisely because the step and the guarantee live in different places and don't need each other to both be correct.
Why the other four stay single-step
ContactForm, NewsletterForm, and SettingsForm are 61–127 lines each and carry two to three fields apiece — name/email/message, email alone, or two account fields. None crosses the field count where decomposition pays for itself: a progress indicator, per-step validation, and state persistence across steps are real engineering cost, and a 2-field form has nothing to spread across two screens that isn't just padding. The tutorials teaching the useState-step-index pattern are implicitly answering "how do I split a 12-field checkout or onboarding form" — a question this storefront's own forms never had to ask, because none of them are that long.
AuthCard is the interesting fourth case: login, signup, forgot-password, and reset-password are four separate fields-sets, but they aren't steps of one form — they're four different pages (src/app/{login,signup,forgot-password,reset-password}/page.tsx), each rendering the shared AuthCard shell with its own children and its own server action. A password-reset flow feels like a multi-step form when you narrate it — "enter your email, then check your mail, then set a new password" — but nothing here holds that journey's state in one component. Each stage is its own URL, its own full page load, and the only thing carried across stages is the Supabase session token, not React state. That's the third row from the table above, and it's the shape this framework's own routing makes free: a Server Component per stage, no client-side wizard machinery at all.
Route-based steps versus client-state steps: when each one is right
A step belongs in route state (a page each) when: the steps are reachable independently (a password-reset link opens step three directly, skipping one and two), a server round-trip legitimately happens between stages (an email verification link, a payment redirect), or losing progress on a refresh is acceptable because each stage re-renders from a URL, not from memory.
A step belongs in client component state (one component, an index in useState) when: every stage needs the same page load's data (you're not waiting on anything external between them), a user should be able to go back without losing what they typed, and the whole thing is one logical submission that just doesn't fit on one screen.
RedemptionPicker picked client state correctly — nothing external happens between "pick" and "confirm," and losing the selection on an accidental refresh would be a bad experience for something with only two fields worth of state to lose. The auth journey picked routes correctly for the opposite reasons — an email link genuinely has to open a specific stage from cold, and nothing about "forgot password" benefits from living in the same component tree as "log in."
Mistakes and how they show up
| Mistake | What happens | The fix |
|---|---|---|
Storing every field from every step in one flat useState object | Any field's onChange re-renders the whole form, steps included | Scope state to the step that owns it, or use an uncontrolled form library if the form is genuinely large |
| Treating a confirmation step as validation | The server never sees which step the client was on, so nothing is actually enforced by it | Validate and authorize in the server action, unconditionally — see redeemSlot() above |
| Making a route-shaped flow (email link, payment redirect) into client-state steps | The link opens the app at step one and there's no way to resume at step three | If a step must be reachable independently, it needs to be a route |
| No per-step validation, only a final submit | Users reach step four before learning step one had an error | Validate the current step before advancing — see the form-validation post for the allowlist-boundary version of this |
| Reaching for a state-machine library for a 2-state boolean | Adds a dependency and an API surface for what useState(false) already does | Match the tool to the state count — RedemptionPicker needed none |
Frequently asked questions
Do I need XState or a similar state machine for a multi-step form in React?
Only once the number of steps and the transitions between them get complex enough that useState calls stop reading clearly — overlapping conditions, steps that can be skipped or revisited in different orders, or async transitions with their own loading/error states. A two- or three-step linear form, like this codebase's one example, doesn't reach that threshold; plain state is easier to read and has nothing extra to learn.
Should each step be its own route or stay as component state? It depends on whether a step needs to be reachable on its own. If someone can arrive at step three from an external link (email, payment redirect) or a refresh should not lose meaningful progress, make it a route. If every step depends on data already loaded and the whole thing is one submission split across screens for readability, component state is simpler and avoids a server round-trip per step.
How do you keep form data if the user refreshes mid-wizard?
For route-based steps, the data already lives wherever the previous step wrote it (a database row, a signed cookie, a query param) — a refresh just re-renders that page. For client-state steps, nothing survives a refresh unless you deliberately persist to sessionStorage or a similar mechanism; whether that's worth doing depends on how much the user would lose, which is exactly the question that decided RedemptionPicker's two fields weren't worth persisting.
Is a confirmation step the same thing as validation?
No, and conflating them is the most common bug class in this space. Validation checks that the data is well-formed; a confirmation step checks that the user meant to submit it. Neither one is enforced unless the server re-checks it — a client-side confirmation that isn't backed by a server-side re-verification (as redeemSlot() does above) can be skipped entirely by anyone who calls the action directly.
Templates where this ships
ASoc Tempo is a time-tracking SaaS landing page, ASoc Till is a POS-system marketing site, and ASoc Timbre is an AI voice-generator landing page — all three ship the same form-component discipline this post describes: steps only where a real irreversible choice or an external round-trip earns them.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the server-action pattern behind every form cited above, read Next.js Server Actions; for where client-side validation stops and a server-side boundary has to start, read React form validation.
