Web Development
ReactReact Hook FormZodNext.jsFormsAnalyticsArchitecture

React Multi-Step Forms at Scale: Save Draft, Resume Later, Validation, and Step Navigation

AO
Adrijan Omićević
·16 min read

# 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#

RequirementVersionNotes
React18+Works with any router
React Hook Form7+Examples assume useForm and FormProvider
Zod3+Schema-first validation
TypeScriptRecommendedStrongly improves safety for branching flows
Backend persistenceAnyREST, 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.

LayerWhat it ownsWhat it must not own
Form stateCurrent values, touched fields, field-level errorsStep routing logic and server persistence
Step flowWhich steps exist, which are visible, ordering, navigation rulesField-level validation logic
ValidationStep schema and final schemaUI components, server I/O
Persistence and analyticsDraft save, resume, submit, event loggingBusiness 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.

FieldTypePurpose
idstringStable identifier shared across devices
userIdstring nullableLink to authenticated user when available
resumeTokenstring nullableMagic-link flow for anonymous users
statusenumdraft, submitted, abandoned
schemaVersionnumberEnables migrations when your form changes
dataJSONPartial answers, not necessarily valid
progressJSONCurrent step key and completion metadata
updatedAttimestampSorting and cleanup
submittedAttimestamp nullableAuditing

Two rules prevent pain later:

  1. 1
    Draft data must accept partial values, even invalid ones.
  2. 2
    Final 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
```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
```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
```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.

TypeScript
```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:

PolicyWhat happens to hidden step dataWhen to use
Keep dataStored but ignored by final schema via refinementWhen users might flip back and forth
Clear dataAutomatically reset hidden fieldsWhen 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 useEffect watching the controlling field, and call setValue with shouldDirty: true so 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
TypeScript
```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:

  1. 1
    Debounced save after changes.
  2. 2
    Save on step transition.
  3. 3
    Save 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:

  • data as partial values
  • progress.currentStepKey
  • progress.visibleStepKeys if useful for debugging, but treat it as informational only
  • schemaVersion

Avoid saving:

  • client-only UI flags
  • validation errors
  • touched state

A Practical Draft Payload#

FieldExampleNotes
draftIddr_123Stable reference
schemaVersion3Drives migrations
datapartial JSONOnly user-entered answers
progress{ currentStepKey: "address" }Keeps resume consistent
clientUpdatedAtISO stringHelps 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
```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:

  • createDraft when the wizard begins
  • updateDraft on subsequent saves
  • submitDraft to finalize and lock

A minimal REST-style contract:

EndpointMethodPurpose
/api/draftsPOSTCreate and return draftId
/api/drafts/:idPATCHSave partial data and progress
/api/drafts/:id/submitPOSTValidate 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 resumeToken and 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:

StrategyHow it worksWhen to use
Last write winsServer overwrites draftSimple consumer flows, low risk
Version checkReject if clientUpdatedAt older than serverBusiness-critical flows
Merge by fieldServer merges patchesComplex, 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:

TypeScript
```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:

  1. 1
    Fetch draft data by draftId or resumeToken.
  2. 2
    If schemaVersion is older, run a migration on the server.
  3. 3
    Return normalized data that matches the current schema shape.
  4. 4
    Initialize defaultValues with 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:

EventWhenProperties you need
wizard_starteddraft createddraftId, schemaVersion, entry source
step_viewedstep mountedstepKey, position, device
step_completedstep validated and next clickedstepKey, time on step
draft_savedsave succeededlatency, payload size
draft_resumeuser loaded existing draftdays since last update
wizard_submittedfinal submit successtotal time, steps count
wizard_errorserver validation failederror 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 fields it owns
  • error path starts with a field root
  • find first step where fields contains 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. 1
    steps.ts with step definitions and schemas.
  2. 2
    wizardFlow.ts with getVisibleSteps, getNextStepKey, getPrevStepKey.
  3. 3
    WizardRoot.tsx with FormProvider, draft loading, and orchestration.
  4. 4
    Step components that only render inputs, no flow logic.
  5. 5
    Persistence layer with createDraft, saveDraft, submitDraft.
  6. 6
    Analytics 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. 1

    Validating the entire schema on every Next click
    It overwhelms users with errors from hidden steps and increases perceived complexity.

  2. 2

    Storing step order in state and mutating it
    Conditional steps will desync navigation, completion status, and analytics.

  3. 3

    Saving drafts without schema versioning
    After one form update, old drafts become un-resumable and support tickets spike.

  4. 4

    Client-only draft storage for “resume later”
    localStorage-only drafts fail across devices and are easy to lose after cache clears.

  5. 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, partial data, 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.