Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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"

PatternWhat changes between stepsURLUsed here
Single-step formNothing — one submitOne URLContactForm, NewsletterForm, SettingsForm
Client-state wizardA step index in useState, all fields held in one componentOne URL, never changesRedemptionPicker — 2 states, not really a wizard
Route-based flowEach "step" is its own page, its own URL, its own server renderChanges per stepThe 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&apos;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

MistakeWhat happensThe fix
Storing every field from every step in one flat useState objectAny field's onChange re-renders the whole form, steps includedScope state to the step that owns it, or use an uncontrolled form library if the form is genuinely large
Treating a confirmation step as validationThe server never sees which step the client was on, so nothing is actually enforced by itValidate and authorize in the server action, unconditionally — see redeemSlot() above
Making a route-shaped flow (email link, payment redirect) into client-state stepsThe link opens the app at step one and there's no way to resume at step threeIf a step must be reachable independently, it needs to be a route
No per-step validation, only a final submitUsers reach step four before learning step one had an errorValidate 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 booleanAdds a dependency and an API surface for what useState(false) already doesMatch 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.

Keep reading

Tutorial9 min read

React Images: 26 `<img>` Tags and Not One `import`

1,160 image files, zero imported ones. The import-vs-string-path choice is really a choice about who verifies the path — and what you have to build once the bundler stops.

Read more
Tutorial9 min read

React Protected Routes: What a Server-Rendered Guard Does Differently

React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.

Read more