How to Implement Multi-Step Forms in React with React Hook Form and Zod Validation

How to Implement Multi-Step Forms in React with React Hook Form and Zod Validation

by | Sep 20, 2026 | Uncategorized | 0 comments

Long forms kill conversion. Splitting a 25-field signup into four short screens is one of the cheapest UX wins you can ship, but the implementation is where most teams get stuck: how do you validate only the current step, keep the data when the user clicks Back, survive a page refresh, and still submit a single, fully validated payload?

This tutorial shows a complete, production-grade multi step form with React Hook Form and Zod. No form library abstraction on top, no hidden magic: just the primitives you already use, wired correctly.

What we are building

A four-step signup wizard:

  1. Account: email, password
  2. Profile: first name, last name, company
  3. Address: street, city, postal code, country
  4. Review: recap plus terms acceptance

With the production concerns that tutorials usually skip:

  • Independent Zod schema per step, plus one merged schema for the final submit
  • Validation triggered only for the fields of the current step
  • Back navigation that never loses data (including browser back button)
  • Draft persistence in sessionStorage with sensitive fields excluded
  • An accessible progress indicator
  • Sending only parsed, validated data to the API, not the raw form state
  • Server-side errors mapped back to the right step
multi step form

Stack and versions

Package Version used Role
react 19.x UI
react-hook-form 7.5x Form state, per-field validation, focus management
zod 4.x Schemas and TypeScript types
@hookform/resolvers 5.x Bridge between Zod and RHF
npm install react-hook-form zod @hookform/resolvers

Everything below works in a Vite SPA, in Next.js App Router (add 'use client'), or in Remix/React Router.

Choose your architecture first

There are two viable patterns. Picking the wrong one is the number one source of pain later. namastedev.com published something useful on the subject.

Pattern How it works Pros Cons
A. One useForm for the whole wizard (recommended) A single form instance shared through FormProvider; steps only render fields State persists for free, one submit handler, easy review step, simple typing Needs trigger() to scope validation to the current step
B. One useForm per step Each step is an isolated form; a parent store (context, Zustand, useReducer) accumulates values Steps are fully independent, easy code splitting, great for very long or plugin-based flows Manual merge, duplicate default values, more glue code

We implement pattern A and show at the end how to swap in a per-step resolver when you need it.

Step 1: Define one Zod schema per step

Each step owns its own schema. The wizard schema is the composition of all of them, which guarantees your final payload type is always in sync.

// schemas.ts
import { z } from 'zod';

export const accountSchema = z.object({
  email: z.email('Enter a valid email address'),
  password: z.string().min(8, 'Use at least 8 characters'),
});

export const profileSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
  company: z.string().optional(),
});

export const addressSchema = z.object({
  street: z.string().min(1, 'Street is required'),
  city: z.string().min(1, 'City is required'),
  zip: z.string().regex(/^[0-9]{4,10}$/, 'Enter a valid postal code'),
  country: z.enum(['FR', 'BE', 'CH', 'CA', 'US']),
});

export const reviewSchema = z.object({
  // boolean + refine instead of z.literal(true): keeps the inferred type boolean,
  // so the default value can legally be false
  terms: z.boolean().refine((v) => v === true, 'You must accept the terms'),
});

export const wizardSchema = accountSchema
  .extend(profileSchema.shape)
  .extend(addressSchema.shape)
  .extend(reviewSchema.shape);

export type WizardValues = z.infer<typeof wizardSchema>;

export const stepSchemas = [accountSchema, profileSchema, addressSchema, reviewSchema];

Zod 3 note: use z.string().email() instead of z.email(), and .merge() instead of .extend(schema.shape).

Declare the steps as data, not as JSX

Describing steps in a config array is what makes progress bars, per-step validation and error mapping trivial later.

// steps.config.ts
import type { WizardValues } from './schemas';

type StepConfig = {
  id: string;
  title: string;
  fields: readonly (keyof WizardValues)[];
};

export const steps: readonly StepConfig[] = [
  { id: 'account', title: 'Account', fields: ['email', 'password'] },
  { id: 'profile', title: 'Profile', fields: ['firstName', 'lastName', 'company'] },
  { id: 'address', title: 'Address', fields: ['street', 'city', 'zip', 'country'] },
  { id: 'review', title: 'Review', fields: ['terms'] },
];

export function stepIndexOfField(name: string) {
  return steps.findIndex((s) => (s.fields as readonly string[]).includes(name));
}
multi step form

Step 2: A single form instance for the whole wizard

Three options matter here:

  • mode: 'onTouched' gives validation feedback after the first blur instead of on every keystroke
  • shouldUnregister: false (the default in RHF v7) keeps values of fields that are no longer mounted, which is exactly what you need when a step unmounts
  • defaultValues must contain every field of every step, otherwise inputs go from uncontrolled to controlled
// SignupWizard.tsx
'use client';

import { useState } from 'react';
import { useForm, FormProvider, type FieldPath } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { wizardSchema, type WizardValues } from './schemas';
import { steps, stepIndexOfField } from './steps.config';
import { AccountStep, ProfileStep, AddressStep, ReviewStep } from './steps';
import Progress from './Progress';

const STEP_VIEWS = [AccountStep, ProfileStep, AddressStep, ReviewStep];

const emptyValues: WizardValues = {
  email: '', password: '',
  firstName: '', lastName: '', company: '',
  street: '', city: '', zip: '', country: 'FR',
  terms: false,
};

export default function SignupWizard() {
  const [current, setCurrent] = useState(0);
  const [submitting, setSubmitting] = useState(false);

  const methods = useForm<WizardValues>({
    resolver: zodResolver(wizardSchema),
    mode: 'onTouched',
    reValidateMode: 'onChange',
    shouldUnregister: false,
    defaultValues: emptyValues,
  });

  const isLast = current === steps.length - 1;
  const StepView = STEP_VIEWS[current];

  async function goNext() {
    const fields = steps[current].fields as FieldPath<WizardValues>[];
    const valid = await methods.trigger(fields, { shouldFocus: true });
    if (!valid) return;
    setCurrent((s) => Math.min(s + 1, steps.length - 1));
  }

  function goBack() {
    setCurrent((s) => Math.max(s - 1, 0));
  }

  async function onSubmit(values: WizardValues) {
    // Never post raw form state: parse it once more and send parsed.data
    const parsed = wizardSchema.safeParse(values);
    if (!parsed.success) {
      const firstField = String(parsed.error.issues[0]?.path[0] ?? '');
      const target = stepIndexOfField(firstField);
      if (target >= 0) setCurrent(target);
      return;
    }

    setSubmitting(true);
    try {
      const res = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(parsed.data),
      });

      if (!res.ok) {
        const body = await res.json();
        // Map server field errors back to the right step
        Object.entries(body.errors ?? {}).forEach(([field, message]) => {
          methods.setError(field as FieldPath<WizardValues>, {
            type: 'server',
            message: String(message),
          });
          const target = stepIndexOfField(field);
          if (target >= 0) setCurrent((s) => Math.min(s, target));
        });
        if (!body.errors) {
          methods.setError('root.serverError', { message: 'Something went wrong, please retry.' });
        }
        return;
      }

      sessionStorage.removeItem('signup-wizard-draft');
      methods.reset(emptyValues);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <FormProvider {...methods}>
      <Progress current={current} steps={steps} />

      <form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
        <StepView />

        {methods.formState.errors.root?.serverError && (
          <p role="alert">{methods.formState.errors.root.serverError.message}</p>
        )}

        <div className="wizard-actions">
          <button type="button" onClick={goBack} disabled={current === 0}>Back</button>

          {isLast ? (
            <button type="submit" disabled={submitting}>
              {submitting ? 'Sending...' : 'Create my account'}
            </button>
          ) : (
            <button type="button" onClick={goNext}>Continue</button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

Why the Next button is type="button"

If it were a submit button, pressing Enter in any input would try to submit the entire wizard from step 1. Keep a single type="submit" button, rendered only on the last step.

How per-step validation actually works

trigger(fields) runs the resolver against the whole schema, then React Hook Form only publishes the errors for the field names you passed. In practice that means:

  • Field-level rules are scoped correctly out of the box
  • Cross-field refinements declared at object level (for example .refine() comparing password and confirmation) attach their error to a path; make sure that path belongs to the step, otherwise the user is blocked with no visible message

If your schema is heavy with object-level refinements, switch to a dynamic resolver:

const methods = useForm<WizardValues>({
  // validate only the current step while the user is filling it
  resolver: async (values, ctx, options) => {
    const schema = isLast ? wizardSchema : stepSchemas[current];
    return zodResolver(schema as never)(values, ctx, options);
  },
  mode: 'onTouched',
  defaultValues: emptyValues,
});

Step 3: Step components read the shared form with useFormContext

Each step stays dumb and reusable. No props drilling, no local state.

// steps/AccountStep.tsx
import { useFormContext } from 'react-hook-form';
import type { WizardValues } from '../schemas';

export function AccountStep() {
  const { register, formState: { errors } } = useFormContext<WizardValues>();

  return (
    <fieldset>
      <legend>Your account</legend>

      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        autoComplete="email"
        aria-invalid={Boolean(errors.email)}
        aria-describedby={errors.email ? 'email-error' : undefined}
        {...register('email')}
      />
      {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        autoComplete="new-password"
        aria-invalid={Boolean(errors.password)}
        {...register('password')}
      />
      {errors.password && <p role="alert">{errors.password.message}</p>}
    </fieldset>
  );
}

The review step simply reads the accumulated values:

// steps/ReviewStep.tsx
import { useFormContext } from 'react-hook-form';
import type { WizardValues } from '../schemas';

export function ReviewStep() {
  const { getValues, register, formState: { errors } } = useFormContext<WizardValues>();
  const v = getValues();

  return (
    <section>
      <h2>Check your details</h2>
      <dl>
        <dt>Email</dt><dd>{v.email}</dd>
        <dt>Name</dt><dd>{v.firstName} {v.lastName}</dd>
        <dt>Address</dt><dd>{v.street}, {v.zip} {v.city} ({v.country})</dd>
      </dl>

      <label>
        <input type="checkbox" {...register('terms')} /> I accept the terms of service
      </label>
      {errors.terms && <p role="alert">{errors.terms.message}</p>}
    </section>
  );
}

Step 4: An accessible progress indicator

A progress bar is not decoration: it is the main reason multi-step forms convert better. Make it readable by screen readers too.

// Progress.tsx
type Props = { current: number; steps: readonly { id: string; title: string }[] };

export default function Progress({ current, steps }: Props) {
  const percent = Math.round(((current + 1) / steps.length) * 100);

  return (
    <div className="progress">
      <p aria-live="polite">
        Step {current + 1} of {steps.length}: {steps[current].title}
      </p>

      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={percent}
      >
        <span style={{ width: percent + '%' }} />
      </div>

      <ol className="progress-steps">
        {steps.map((s, i) => (
          <li key={s.id} aria-current={i === current ? 'step' : undefined}>
            {s.title}
          </li>
        ))}
      </ol>
    </div>
  );
}

Also move focus to the step heading after each transition, otherwise keyboard and screen reader users stay stuck at the bottom of the page:

const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => { headingRef.current?.focus(); }, [current]);
// <h2 ref={headingRef} tabIndex={-1}>{steps[current].title}</h2>
multi step form

Step 5: Persist the draft between steps and page reloads

With one shared useForm, moving between steps already keeps everything in memory. Refreshing the page does not. Subscribe to watch and store a sanitized snapshot.

const DRAFT_KEY = 'signup-wizard-draft';

function loadDraft(): Partial<WizardValues> {
  if (typeof window === 'undefined') return {};
  try {
    return JSON.parse(sessionStorage.getItem(DRAFT_KEY) ?? '{}');
  } catch {
    return {};
  }
}

// inside the wizard component
const methods = useForm<WizardValues>({
  resolver: zodResolver(wizardSchema),
  mode: 'onTouched',
  defaultValues: { ...emptyValues, ...loadDraft() },
});

useEffect(() => {
  const subscription = methods.watch((values) => {
    const { password, ...safe } = values;   // never persist secrets
    sessionStorage.setItem(DRAFT_KEY, JSON.stringify(safe));
  });
  return () => subscription.unsubscribe();
}, [methods]);

Rules that save you from real incidents:

  • Use sessionStorage rather than localStorage for anything remotely personal
  • Never persist passwords, card data, tokens, national ID numbers
  • Version the key (signup-wizard-draft-v2) so an old draft never hydrates a new schema
  • Clear the draft on successful submit
  • In SSR frameworks, hydrate with reset() inside useEffect instead of defaultValues to avoid a hydration mismatch

Step 6: Make the browser Back button behave

Users press the hardware or browser back button, not your Back button. Sync the step with the URL and you get shareable, refreshable, bookmarkable steps for free.

function goTo(index: number) {
  setCurrent(index);
  const url = new URL(window.location.href);
  url.searchParams.set('step', String(index + 1));
  window.history.pushState({ step: index }, '', url);
}

useEffect(() => {
  function onPopState() {
    const raw = Number(new URLSearchParams(window.location.search).get('step') ?? 1);
    const next = Math.min(Math.max(raw - 1, 0), steps.length - 1);
    setCurrent(next);
  }
  window.addEventListener('popstate', onPopState);
  return () => window.removeEventListener('popstate', onPopState);
}, []);

Two extra guards worth adding:

  1. Do not allow deep linking to step 4 if steps 1 to 3 are invalid. On mount, validate the fields of every previous step with trigger() and clamp the index to the first invalid step.
  2. Warn before leaving when the form is dirty: beforeunload plus your router blocker if you use one.
useEffect(() => {
  const handler = (e: BeforeUnloadEvent) => {
    if (!methods.formState.isDirty) return;
    e.preventDefault();
    e.returnValue = '';
  };
  window.addEventListener('beforeunload', handler);
  return () => window.removeEventListener('beforeunload', handler);
}, [methods.formState.isDirty]);

Step 7: Submit only validated data

A wizard accumulates junk: abandoned branches, UI-only toggles, fields from a conditional step the user skipped. Sending getValues() straight to the API is how you get 500s in production.

Three habits:

  • Always send wizardSchema.parse(values) output. Zod strips unknown keys by default, so your payload matches the contract exactly.
  • Use a dedicated output schema when the API shape differs: wizardSchema.omit({ terms: true }).transform(...).
  • Re-validate the same schema on the server. Client validation is UX, server validation is security.
// api payload derived from the same source of truth
export const signupPayloadSchema = wizardSchema
  .omit({ terms: true })
  .transform((v) => ({
    email: v.email.toLowerCase().trim(),
    password: v.password,
    profile: { firstName: v.firstName, lastName: v.lastName, company: v.company || null },
    address: { street: v.street, city: v.city, zip: v.zip, country: v.country },
  }));

export type SignupPayload = z.infer<typeof signupPayloadSchema>;
multi step form

Conditional and dynamic steps

Real flows branch: a business account asks for a VAT number, an individual does not. Keep the step list derived from the current values instead of hardcoded.

const accountType = methods.watch('accountType');

const activeSteps = useMemo(
  () => steps.filter((s) => s.id !== 'billing' || accountType === 'business'),
  [accountType]
);

Then compute current against activeSteps. For the schema side, a discriminated union keeps types honest:

const wizardSchema = z.discriminatedUnion('accountType', [
  baseSchema.extend({ accountType: z.literal('individual') }),
  baseSchema.extend({ accountType: z.literal('business'), vatNumber: z.string().min(5) }),
]);

Common pitfalls and how to fix them

Symptom Cause Fix
Values are lost when going back shouldUnregister: true or a useForm per step without a store Keep shouldUnregister: false and one shared form instance
Next button blocked with no visible error A schema error is attached to a field outside the current step Use a per-step resolver, or render a global error summary
Form submits on Enter from step 1 Next button is type="submit" Use type="button" and render submit only on the last step
Warning: uncontrolled to controlled input Missing keys in defaultValues Provide every field, use '' and not undefined
Whole wizard re-renders on each keystroke Using watch() at the top level Use useWatch in a child, or the watch subscription callback
Hydration mismatch in Next.js Reading sessionStorage during render Hydrate the draft with reset() inside useEffect
Errors appear too aggressively mode: 'onChange' Prefer onTouched with reValidateMode: 'onChange'

Testing checklist before you ship

  1. Fill step 1, go to step 3, come back: values are still there
  2. Refresh on step 3: the draft is restored, the password field is empty
  3. Browser back and forward buttons move between steps without breaking state
  4. Submit with a server error on an email already used: the wizard jumps back to step 1 with the message under the field
  5. Keyboard only run: tab order, focus on step change, Enter never skips a step
  6. Screen reader announces Step 2 of 4 after each transition
  7. Slow 3G: the submit button is disabled and no double submission occurs

FAQ

Is React Hook Form good for multi-step forms?

Yes, it is arguably the best fit in the React ecosystem. Its uncontrolled architecture means fields keep their values in the form state even after unmounting, trigger() gives you native per-step validation, and FormProvider removes props drilling between steps. Add Zod and you get one typed source of truth for validation and payload.

How do I validate only the current step?

Call await trigger(fieldsOfCurrentStep, { shouldFocus: true }) before advancing. If your schema relies on object-level refinements, pass a per-step schema to the resolver instead and use the merged schema only on final submit.

Should I use one useForm or one per step?

One shared instance for most wizards (up to roughly 8 steps): less code, easier review step, free persistence. Go with one form per step plus a store (useReducer, Zustand, context) when steps are lazy loaded, owned by different teams, or dynamically composed from a backend definition.

How do I keep the data if the user refreshes the page?

Subscribe to watch and persist a sanitized snapshot in sessionStorage, then rehydrate with reset(). Exclude passwords and any sensitive field, and version your storage key so schema changes cannot corrupt a returning user.

Does this work with shadcn/ui, MUI or Ant Design?

Yes. The step logic is UI agnostic. For controlled component libraries such as MUI or the shadcn/ui Form primitives, wrap fields with <Controller> or useController instead of register. Nothing else changes.

Can I use it with Next.js Server Actions?

Yes. Keep the wizard as a client component, and in onSubmit call the server action with parsed.data. Validate with the exact same Zod schema on the server and return field errors that you map back with setError.

What about very long forms, like 50 fields?

Split them into logical groups of 4 to 7 fields, save a draft server side after each step (so users can resume on another device), and consider lazy loading step components with React.lazy. The pattern above scales without changes.

Wrapping up

A solid multi step form with React Hook Form comes down to five decisions: one shared form instance, one Zod schema per step composed into a merged schema, trigger() for scoped validation, a persisted and sanitized draft, and a submit that sends parsed data only. Everything else, progress bars, branching, server error mapping, plugs into that foundation.

At Box Software we build and audit React front ends where forms are the revenue path: signup funnels, quote requests, onboarding flows. If you want a second pair of eyes on your wizard, its accessibility or its conversion rate, get in touch with our team. formity.app makes the same point with more data.