# What You’ll Build#
A production-ready React multi step form has requirements that single-page forms rarely face: partial completion, navigation back and forth, conditional steps, offline-ish draft behavior, and analytics you can trust.
This guide shows an approach that scales to long wizards and complex branching while staying maintainable. You will use React Hook Form for performant state management and Zod for validation, then add server-side draft persistence and instrumentation.
If you want a deeper baseline on form architecture, start with React forms at scale with React Hook Form and Zod. If you are building on Next.js App Router, also see multi-step wizard with Server Actions and Zod and forms with React Hook Form, Zod, and Server Actions.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| React | 18+ | Works with any router |
| React Hook Form | 7+ | Examples assume useForm and FormProvider |
| Zod | 3+ | Schema-first validation |
| TypeScript | Recommended | Strongly improves safety for branching flows |
| Backend persistence | Any | REST, GraphQL, Server Actions, or RPC |
ℹ️ Note: This article uses Next.js-friendly patterns, but the core design applies to any React stack. The key is separating step UI from persistence and flow rules.
# Architecture: The 4 Problems You Must Solve#
A React multi step form that survives real production traffic needs four distinct layers. Mixing them is why most wizards become brittle.
| Layer | What it owns | What it must not own |
|---|---|---|
| Form state | Current values, touched fields, field-level errors | Step routing logic and server persistence |
| Step flow | Which steps exist, which are visible, ordering, navigation rules | Field-level validation logic |
| Validation | Step schema and final schema | UI components, server I/O |
| Persistence and analytics | Draft save, resume, submit, event logging | Business rules embedded in components |
A common anti-pattern is putting flow decisions in component state like setSteps([...]) and persisting the entire React Hook Form state as-is. That breaks as soon as you add conditional steps, version schemas, or support resuming older drafts.
# Data Model: Design for Drafts and Versioning#
Draft persistence works best when your server stores an explicit draft record instead of only storing final submissions.
A practical draft model looks like this.
| Field | Type | Purpose |
|---|---|---|
id | string | Stable identifier shared across devices |
userId | string nullable | Link to authenticated user when available |
resumeToken | string nullable | Magic-link flow for anonymous users |
status | enum | draft, submitted, abandoned |
schemaVersion | number | Enables migrations when your form changes |
data | JSON | Partial answers, not necessarily valid |
progress | JSON | Current step key and completion metadata |
updatedAt | timestamp | Sorting and cleanup |
submittedAt | timestamp nullable | Auditing |
Two rules prevent pain later:
- 1Draft
datamust accept partial values, even invalid ones. - 2Final submission must validate against the strict schema, even if draft saving tolerated more.
🎯 Key Takeaway: Treat “draft” as a first-class resource with its own schema and lifecycle, not as an unfinished submission.
# Step Definitions: Make Steps Pure and Derivable#
Instead of hardcoding a step list in JSX, define steps as data. Each step should declare:
- a stable
key - a
title - fields it owns
- a Zod schema subset for step-level validation
- a
isVisible(values)predicate for conditional steps - optional navigation guards
This keeps conditional logic out of component trees and makes analytics consistent.
```typescript
import \{ z \} from "zod";
export const fullSchema = z.object(\{
accountType: z.enum(["personal", "business"]),
email: z.string().email(),
companyName: z.string().min(2).optional(),
vatId: z.string().min(6).optional(),
address: z.object(\{
country: z.string().min(2),
city: z.string().min(2),
\}),
\});
export type FormValues = z.infer<typeof fullSchema>;
type StepDef = \{
key: string;
title: string;
fields: (keyof FormValues | "address.country" | "address.city")[];
schema: z.ZodTypeAny;
isVisible?: (values: Partial<FormValues>) => boolean;
\};
export const steps: StepDef[] = [
\{
key: "account",
title: "Account",
fields: ["accountType", "email"],
schema: fullSchema.pick(\{ accountType: true, email: true \}),
\},
\{
key: "business",
title: "Business details",
fields: ["companyName", "vatId"],
schema: z.object(\{
companyName: z.string().min(2),
vatId: z.string().min(6),
\}),
isVisible: (v) => v.accountType === "business",
\},
\{
key: "address",
title: "Address",
fields: ["address.country", "address.city"],
schema: fullSchema.pick(\{ address: true \}),
\},
];The business step is conditional and driven by current values. No step array mutation, no duplicated branching logic.
⚠️ Warning: Avoid “conditional rendering only” where you hide fields but keep them registered with required validation. It creates hidden errors that block submission and confuses users.
# React Hook Form Setup: One Form, Many Steps#
Use a single useForm instance for the entire wizard. Switching steps should not recreate the form, otherwise you will lose dirty tracking, touched state, and performance benefits.
```tsx
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const draftSchema = fullSchema.partial();
export function WizardRoot(props: { initialData?: Partial<FormValues> }) {
const methods = useForm<FormValues>({
mode: "onBlur",
resolver: zodResolver(draftSchema),
defaultValues: props.initialData as any,
});
return (
<FormProvider {...methods}>
{/* Step router renders current step component */}
</FormProvider>
);
}Two important choices here:
- Use a partial schema resolver for drafts so you can save incomplete values without immediate hard failures.
- Still run step-level validation on navigation, and strict full-schema validation on final submit.
# Validation Strategy: Step-Level + Final Gate#
Users expect “Next” to tell them what is missing on the current step, not on a future step they have not seen. At the same time, your backend needs a single strict validation at submission.
Use this pattern:
- On “Next”, validate only fields in the current step schema.
- On “Submit”, validate the full schema on the server.
React Hook Form lets you trigger validation on specific field paths.
```tsx
import \{ useFormContext \} from "react-hook-form";
function useStepValidation(stepFields: string[]) \{
const \{ trigger \} = useFormContext();
async function validateCurrentStep() \{
return trigger(stepFields as any, \{ shouldFocus: true \});
\}
return \{ validateCurrentStep \};
\}If you have cross-field dependencies within a step, keep them inside that step schema. If you have cross-step dependencies, validate them at final submit and provide a clear message and a jump link back to the relevant step.
# Conditional Steps: Compute Visible Steps From Values#
The simplest scalable rule: the step list should always be computed from current answers, not stored.
```ts
export function getVisibleSteps(
allSteps: typeof steps,
values: Partial<FormValues>
) {
return allSteps.filter((s) => (s.isVisible ? s.isVisible(values) : true));
}When a user changes accountType from business to personal, the business step disappears. You must then decide what to do with its data.
There are two safe policies:
| Policy | What happens to hidden step data | When to use |
|---|---|---|
| Keep data | Stored but ignored by final schema via refinement | When users might flip back and forth |
| Clear data | Automatically reset hidden fields | When data has compliance or pricing impact |
In B2B onboarding, “keep data” is usually better UX. In regulated flows, “clear data” prevents accidental submission of stale sensitive values.
💡 Tip: If you clear data, do it at the moment the condition becomes false, not on render. Use a
useEffectwatching the controlling field, and callsetValuewithshouldDirty: trueso auto-save picks it up.
# Step Navigation That Never Breaks#
Navigation should be based on step keys, not indexes. Index-based navigation breaks the moment conditional steps change.
A robust flow model:
- current step key
- derived visible step keys
- helper functions to go next, previous, or jump to a key if allowed
```ts
export function getNextStepKey(
visibleKeys: string[],
currentKey: string
) \{
const idx = visibleKeys.indexOf(currentKey);
if (idx === -1) return visibleKeys[0];
return visibleKeys[idx + 1] ?? currentKey;
\}
export function getPrevStepKey(
visibleKeys: string[],
currentKey: string
) \{
const idx = visibleKeys.indexOf(currentKey);
if (idx <= 0) return currentKey;
return visibleKeys[idx - 1];
\}For “jump navigation” such as a stepper, don’t allow skipping required steps without validation. Allow jumping back freely, and jumping forward only to steps already completed.
Track completion per step based on validated passes, not based on presence of values. Presence-only completion is why users get “complete” states with invalid emails.
# Draft Save: Client UX and Server Contracts#
Draft saving is not a feature, it is a system. Users expect it to be reliable, fast, and conflict-free across tabs and devices.
When to Save#
Use three triggers:
- 1Debounced save after changes.
- 2Save on step transition.
- 3Save on visibility change of the page, if supported.
For debounced save, do not send the entire form on every keystroke in large forms. A good baseline is debounce between 800 and 1500 ms, and send only dirtyFields deltas when possible.
What to Save#
Save:
dataas partial valuesprogress.currentStepKeyprogress.visibleStepKeysif useful for debugging, but treat it as informational onlyschemaVersion
Avoid saving:
- client-only UI flags
- validation errors
- touched state
A Practical Draft Payload#
| Field | Example | Notes |
|---|---|---|
draftId | dr_123 | Stable reference |
schemaVersion | 3 | Drives migrations |
data | partial JSON | Only user-entered answers |
progress | { currentStepKey: "address" } | Keeps resume consistent |
clientUpdatedAt | ISO string | Helps resolve conflicts |
# Implementing Auto-Save With React Hook Form#
Use watch to observe changes and a debounce to reduce calls. Always guard against saving while a save is already in flight.
```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { useFormContext } from "react-hook-form";
function useDebouncedDraftSave(saveFn: (data: any) => Promise<void>) {
const { watch, getValues, formState } = useFormContext();
const [saving, setSaving] = useState(false);
const lastSavedHash = useRef<string>("");
const subscription = watch(() => {
// no-op, subscription triggers effect
});
useEffect(() => {
const id = setTimeout(async () => {
if (!formState.isDirty || saving) return;
const values = getValues();
const hash = JSON.stringify(values);
if (hash === lastSavedHash.current) return;
setSaving(true);
try {
await saveFn(values);
lastSavedHash.current = hash;
} finally {
setSaving(false);
}
}, 1000);
return () => clearTimeout(id);
}, [subscription, formState.isDirty, saving, getValues, saveFn]);
return { saving };
}This simple approach sends the whole object. At scale, prefer saving a patch. If your form is large, JSON stringify can also become expensive. A more scalable approach is to track a lightweight hash per step or send dirtyFields patches only.
⚠️ Warning:
watch()can re-run frequently. If you see performance issues, subscribe only to the fields of the current step, or build step-level forms that register fields only when mounted.
# Server-Side Persistence: Endpoints, Identities, and Conflicts#
You need two server operations:
createDraftwhen the wizard beginsupdateDrafton subsequent savessubmitDraftto finalize and lock
A minimal REST-style contract:
| Endpoint | Method | Purpose |
|---|---|---|
/api/drafts | POST | Create and return draftId |
/api/drafts/:id | PATCH | Save partial data and progress |
/api/drafts/:id/submit | POST | Validate full schema and submit |
Identity: Logged In and Anonymous#
Cross-device resume requires a stable identity:
- For authenticated users, tie drafts to
userId. - For anonymous users, issue a
resumeTokenand send a magic link by email.
Store the draftId client-side in localStorage to resume on the same device. Do not rely on localStorage alone for cross-device.
Conflict Resolution#
If the user edits in two tabs or devices, you need a policy:
| Strategy | How it works | When to use |
|---|---|---|
| Last write wins | Server overwrites draft | Simple consumer flows, low risk |
| Version check | Reject if clientUpdatedAt older than server | Business-critical flows |
| Merge by field | Server merges patches | Complex, only if necessary |
In practice, version checks are a good middle ground. If you reject due to conflict, fetch the latest server draft, show a banner, and let the user continue with the latest.
# Next.js App Router: Server Actions Pattern#
If you are using App Router, Server Actions can simplify drafts by keeping validation and persistence close to the server. The important part is still the same: draft updates accept partial data, final submit is strict.
You can see a complete step wizard example in Next.js App Router multi-step wizard with Server Actions and Zod.
A simplified Server Action sketch:
```ts
"use server";
import \{ z \} from "zod";
const draftUpdateSchema = z.object(\{
draftId: z.string(),
schemaVersion: z.number(),
data: fullSchema.partial(),
progress: z.object(\{
currentStepKey: z.string(),
\}),
\});
export async function saveDraftAction(input: unknown) \{
const parsed = draftUpdateSchema.parse(input);
// Persist parsed.data and parsed.progress
// Return updatedAt and any migration hints
return \{ ok: true \};
\}For final submission, parse with the strict fullSchema on the server, not on the client. This protects you from tampered requests and ensures consistent enforcement.
If you want more form patterns around Server Actions, reference Next.js forms with React Hook Form, Zod, and Server Actions.
# Resume Later: Rehydration and Schema Migrations#
Resuming is more than setting default values. You must handle cases where the form changed since the draft was created.
Rehydrate Safely#
Process:
- 1Fetch draft data by
draftIdorresumeToken. - 2If
schemaVersionis older, run a migration on the server. - 3Return normalized data that matches the current schema shape.
- 4Initialize
defaultValueswith that normalized data.
Never try to “best effort” migrate on the client. It leads to inconsistent behavior across devices and makes debugging painful.
Schema Migration Example#
Migration can be simple mapping, like splitting a single fullName into firstName and lastName, or renaming fields.
Keep migrations linear and testable: v1 -> v2 -> v3. Avoid skipping directly unless you have strong test coverage.
# Analytics Instrumentation: Make the Funnel Actionable#
If you are not instrumenting, you cannot improve completion. Multi-step forms often have drop-off concentrated in one step, one field, or one platform.
Industry benchmarks vary, but long multi-step flows commonly see 20 to 60 percent drop-off depending on friction, device mix, and perceived value. Your goal is to locate the biggest friction points quickly.
Track at least these events:
| Event | When | Properties you need |
|---|---|---|
wizard_started | draft created | draftId, schemaVersion, entry source |
step_viewed | step mounted | stepKey, position, device |
step_completed | step validated and next clicked | stepKey, time on step |
draft_saved | save succeeded | latency, payload size |
draft_resume | user loaded existing draft | days since last update |
wizard_submitted | final submit success | total time, steps count |
wizard_error | server validation failed | error codes, step mapping |
How to Map Validation Errors Back to Steps#
Server-side strict validation will produce field paths. Use your step definitions to map each error path to a step key, then return a structured error response so the UI can deep-link.
Example mapping strategy:
- each step declares
fieldsit owns - error path starts with a field root
- find first step where
fieldscontains the root
This lets you show a banner like “Fix 2 issues in Address step” and a button that navigates there.
💡 Tip: Track “time on step” as
step_completed.timestamp - step_viewed.timestamp. It is often a better friction signal than raw error counts, especially on mobile.
# Putting It Together: A Practical Flow#
A scalable implementation typically has these pieces:
- 1
steps.tswith step definitions and schemas. - 2
wizardFlow.tswithgetVisibleSteps,getNextStepKey,getPrevStepKey. - 3
WizardRoot.tsxwithFormProvider, draft loading, and orchestration. - 4Step components that only render inputs, no flow logic.
- 5Persistence layer with
createDraft,saveDraft,submitDraft. - 6Analytics wrapper that logs the core events.
The key is that steps are dumb, the flow is derived, and the server is the source of truth for drafts and final validation.
# Common Pitfalls in React Multi Step Form Projects#
- 1
Validating the entire schema on every Next click
It overwhelms users with errors from hidden steps and increases perceived complexity. - 2
Storing step order in state and mutating it
Conditional steps will desync navigation, completion status, and analytics. - 3
Saving drafts without schema versioning
After one form update, old drafts become un-resumable and support tickets spike. - 4
Client-only draft storage for “resume later”
localStorage-only drafts fail across devices and are easy to lose after cache clears. - 5
No server-side strict validation on submit
Client validation is helpful UX, but it is not a security boundary.
# Key Takeaways#
- Define steps as data with stable keys, visibility predicates, and step schemas to avoid brittle conditional rendering.
- Use one React Hook Form instance for the whole wizard, validate per step on navigation, and validate strictly on the server on submit.
- Treat drafts as first-class server resources with
schemaVersion, partialdata, and explicit progress for cross-device resume. - Make navigation key-based, not index-based, and compute visible steps from current values on every render.
- Instrument the funnel with step view, completion, save latency, resume events, and error-to-step mapping to target real drop-off points.
# Conclusion#
A React multi step form that saves drafts, resumes across devices, and supports conditional steps is mostly an architecture problem, not a UI problem. When you separate step definitions, flow derivation, validation layers, and persistence contracts, you get a wizard that can grow without breaking.
If you want Samioda to help you implement a scalable wizard in React or Next.js, including server-side drafts, Zod validation, and analytics instrumentation, contact us via samioda.com and we will review your current flow and propose an implementation plan.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →React File Upload UX Patterns: Drag-and-Drop, Progress, Validation, and Failure Recovery
A practical guide to React file upload UX: drag-and-drop, progress feedback, client-side validation, chunking, pause and resume, optimistic UI, and resilient retry strategies with accessible component architecture.
Feature Gating in Next.js (App Router): Permissions, Entitlements, and Billing-Aware Access
Learn Next.js feature gating patterns for App Router: gate UI, server actions, and APIs by role, plan, entitlements, and Stripe subscription status with consistent checks.
Next.js + Stripe Webhooks in the App Router: A Reliability-First Implementation Guide
A production-ready guide to Next.js Stripe webhooks in the App Router — signature verification, raw body handling, idempotency, retries, event ordering, Stripe CLI testing, and troubleshooting.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.
Building a Multi‑Step Wizard in Next.js App Router with Server Actions + Zod (No Extra API Layer)
Implement a production-grade Next.js multi step form using App Router Server Actions and Zod — with three state strategies (cookies, DB drafts, URL), accessible UX, optimistic transitions, and robust error handling without adding an API layer.
Next.js App Router Forms in 2026: React Hook Form + Zod + Server Actions (Validation, Errors, UX)
A production-ready architecture for Next.js App Router forms using React Hook Form, Zod, and Server Actions — with shared schemas, secure server-side validation, async checks, file uploads, and consistent error handling.