Build a React Multi-Step Form Wizard with React Hook Form and Zod

Build a robust React multi-step form wizard with React Hook Form and Zod: step-level validation, persistence, and accessibility. Includes full code.

ASOasis
9 min read
Build a React Multi-Step Form Wizard with React Hook Form and Zod

Image used for representation purposes only.

Overview

Multi-step (wizard) forms break complex data entry into digestible screens, provide clear progress, and reduce user error by validating a step at a time. In this tutorial you’ll build a production-ready React multi-step form wizard with:

  • React 18 + TypeScript
  • React Hook Form for performant controlled/uncontrolled inputs
  • Zod for schema-based validation
  • Step-level validation guards
  • Keyboard- and screen reader–friendly navigation
  • Optional localStorage persistence and async submission

By the end, you’ll have a reusable pattern you can drop into any app.

What we’ll build

We’ll create a three-step signup flow plus a review screen:

  1. Account: email, password
  2. Profile: first name, last name, birthday
  3. Address: country, city, postal code
  4. Review & Submit: confirm and send

We’ll validate only the fields on the current step before allowing users to proceed. The wizard will also persist progress to localStorage and show a stepper with progress.

Prerequisites

  • Familiarity with React hooks and props
  • Node.js 18+ installed
  • Basic TypeScript knowledge (we’ll annotate types but keep it approachable)

Project setup

You can use any React bootstrapping tool. Here’s a quick Vite setup with TypeScript:

# 1) Create the project
npm create vite@latest react-wizard -- --template react-ts
cd react-wizard

# 2) Install deps
npm i react-hook-form zod @hookform/resolvers

# 3) Start dev server
npm run dev

Folder structure

Keep it simple:

src/
  App.tsx
  components/
    MultiStepWizard.tsx

We’ll place the entire wizard in a single file for clarity. In real projects, split each step and shared inputs into their own files.

Build the schema and types

We’ll model each step’s fields with Zod, then merge them into a single FormSchema used by React Hook Form. We’ll also define which fields belong to each step to enable step-level validation.

// src/components/MultiStepWizard.tsx
import React from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

// Step schemas
const AccountSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'Min 8 characters')
});

const ProfileSchema = z.object({
  firstName: z.string().min(2, 'Min 2 characters'),
  lastName: z.string().min(2, 'Min 2 characters'),
  birthday: z
    .string()
    .refine(v => !Number.isNaN(Date.parse(v)), { message: 'Select a valid date' })
});

const AddressSchema = z.object({
  country: z.string().min(1, 'Country is required'),
  city: z.string().min(1, 'City is required'),
  postalCode: z
    .string()
    .min(4, 'Min 4 characters')
    .regex(/^[A-Za-z0-9\s-]+$/, 'Only letters, numbers, spaces, and dashes')
});

const FormSchema = AccountSchema.merge(ProfileSchema).merge(AddressSchema);
export type FormData = z.infer<typeof FormSchema>;

const steps = ['Account', 'Profile', 'Address', 'Review'] as const;

// Which fields belong to each step (except Review)
const stepFields: (Array<keyof FormData>)[] = [
  ['email', 'password'],
  ['firstName', 'lastName', 'birthday'],
  ['country', 'city', 'postalCode'],
  [] // Review step doesn't add fields
];

Shared input and error components

To keep markup clean, create a small Input component that knows how to register with React Hook Form and display errors.

function Field({ name, label, type = 'text', placeholder }: {
  name: keyof FormData;
  label: string;
  type?: string;
  placeholder?: string;
}) {
  const {
    register,
    formState: { errors }
  } = useFormContext<FormData>();

  const err = errors[name]?.message as string | undefined;

  return (
    <div style={{ marginBottom: 16 }}>
      <label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }} htmlFor={String(name)}>
        {label}
      </label>
      <input
        id={String(name)}
        type={type}
        aria-invalid={!!err}
        aria-describedby={err ? `${String(name)}-error` : undefined}
        {...register(name)}
        placeholder={placeholder}
        style={{
          width: '100%',
          padding: '10px 12px',
          border: `1px solid ${err ? '#e11d48' : '#cbd5e1'}`,
          borderRadius: 8
        }}
      />
      {err && (
        <div id={`${String(name)}-error`} role="alert" style={{ color: '#e11d48', marginTop: 6 }}>
          {err}
        </div>
      )}
    </div>
  );
}

Build the stepper UI

The stepper communicates progress and improves navigation for assistive tech.

function Stepper({ current }: { current: number }) {
  return (
    <nav aria-label="Progress" style={{ marginBottom: 24 }}>
      <ol style={{ display: 'flex', gap: 12, listStyle: 'none', padding: 0 }}>
        {steps.map((label, i) => {
          const reached = i <= current;
          return (
            <li key={label} aria-current={i === current ? 'step' : undefined}>
              <span
                style={{
                  padding: '8px 12px',
                  borderRadius: 999,
                  background: reached ? '#2563eb' : '#e5e7eb',
                  color: reached ? 'white' : '#111827',
                  fontWeight: 600
                }}
              >
                {i + 1}. {label}
              </span>
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

Implement the step screens

Each step renders its own fields. The Review step shows a summary.

function AccountStep() {
  return (
    <>
      <Field name="email" label="Email" type="email" placeholder="you@example.com" />
      <Field name="password" label="Password" type="password" placeholder="••••••••" />
    </>
  );
}

function ProfileStep() {
  return (
    <>
      <Field name="firstName" label="First name" placeholder="Ada" />
      <Field name="lastName" label="Last name" placeholder="Lovelace" />
      <Field name="birthday" label="Birthday" type="date" />
    </>
  );
}

function AddressStep() {
  return (
    <>
      <Field name="country" label="Country" placeholder="United States" />
      <Field name="city" label="City" placeholder="Seattle" />
      <Field name="postalCode" label="Postal code" placeholder="98101" />
    </>
  );
}

function ReviewStep({ data }: { data: FormData }) {
  const Row = ({ k, v }: { k: string; v: React.ReactNode }) => (
    <div style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
      <strong>{k}</strong>
      <span>{v as any}</span>
    </div>
  );

  return (
    <div aria-live="polite">
      <Row k="Email" v={data.email} />
      <Row k="First name" v={data.firstName} />
      <Row k="Last name" v={data.lastName} />
      <Row k="Birthday" v={data.birthday} />
      <Row k="Country" v={data.country} />
      <Row k="City" v={data.city} />
      <Row k="Postal code" v={data.postalCode} />
      <p style={{ marginTop: 12, color: '#475569' }}>Use Back to make changes before submitting.</p>
    </div>
  );
}

Bring it together: the Wizard

The wizard coordinates step state, validation guards, persistence, and final submit.

export default function MultiStepWizard() {
  const [step, setStep] = React.useState(0);

  // Load saved values (if any)
  const saved = React.useMemo(() => {
    try {
      return JSON.parse(localStorage.getItem('signup:data') || 'null') as Partial<FormData> | null;
    } catch {
      return null;
    }
  }, []);

  const methods = useForm<FormData>({
    resolver: zodResolver(FormSchema),
    mode: 'onTouched',
    defaultValues: {
      email: '',
      password: '',
      firstName: '',
      lastName: '',
      birthday: '',
      country: '',
      city: '',
      postalCode: '',
      ...saved
    }
  });

  const { handleSubmit, trigger, getValues, watch, formState } = methods;
  const values = watch();

  // Persist on change (debounced-lite)
  React.useEffect(() => {
    const id = setTimeout(() => {
      localStorage.setItem('signup:data', JSON.stringify(getValues()));
    }, 150);
    return () => clearTimeout(id);
  }, [values, getValues]);

  async function next() {
    // Validate only current step’s fields
    const valid = await trigger(stepFields[step] as (keyof FormData)[], { shouldFocus: true });
    if (valid) setStep(s => Math.min(s + 1, steps.length - 1));
  }

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

  async function onSubmit(data: FormData) {
    // Simulate API call
    await new Promise(r => setTimeout(r, 900));
    console.log('Submitted:', data);
    localStorage.removeItem('signup:data');
    alert('Success! Check the console for submitted payload.');
  }

  const isLast = step === steps.length - 1;

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 640, margin: '40px auto', padding: 24, border: '1px solid #e5e7eb', borderRadius: 12 }}>
        <h1 style={{ marginTop: 0 }}>Create your account</h1>
        <Stepper current={step} />

        <div role="group" aria-labelledby="step-heading" style={{ marginBottom: 16 }}>
          <h2 id="step-heading" style={{ fontSize: 20, marginTop: 0 }}>{steps[step]}</h2>
          {step === 0 && <AccountStep />}
          {step === 1 && <ProfileStep />}
          {step === 2 && <AddressStep />}
          {step === 3 && <ReviewStep data={getValues()} />}
        </div>

        <div style={{ display: 'flex', gap: 12, justifyContent: 'space-between' }}>
          <button type="button" onClick={back} disabled={step === 0 || formState.isSubmitting}>
            Back
          </button>

          {isLast ? (
            <button type="submit" disabled={formState.isSubmitting}>
              {formState.isSubmitting ? 'Submitting…' : 'Confirm & Submit'}
            </button>
          ) : (
            <button type="button" onClick={next}>
              Next
            </button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

Finally, render the wizard in your app:

// src/App.tsx
import MultiStepWizard from './components/MultiStepWizard';
export default function App() {
  return <MultiStepWizard />;
}

Why this approach works

  • Single source of truth: One React Hook Form instance manages all data, so switching steps is cheap and predictable.
  • Step-specific validation: We use the full Zod schema for strong types, yet validate only the current step’s fields with trigger([…fields]).
  • Performance: React Hook Form keeps inputs uncontrolled by default and tracks minimal state, avoiding expensive re-renders common with fully controlled inputs.
  • Resilience: localStorage persistence lets users refresh without losing progress.
  • Accessibility: The stepper uses aria-current, form fields announce errors with role=“alert” and aria-describedby, and keyboard users can tab through logically.

UX polish ideas

  • Disable Next until current step is valid (use formState and trigger on change)
  • Add a visual progress bar (width = (currentStep / (steps.length-1)) * 100%)
  • Show password strength meter and reveal toggle
  • Use country/city autocompletes and mask postal codes by country
  • Add “Save and finish later” via server-side drafts
  • Animate step transitions with CSS or Framer Motion

Common pitfalls and fixes

  • Losing values on step change: Ensure inputs are mounted or that you call keepValues when unmounting. Our approach keeps a single form and unmounting doesn’t lose values since RHF stores them.
  • Validating the whole form on every Next: This causes user friction. Validate only the fields for the current step.
  • Confusing error focus: Pass shouldFocus: true to trigger to move focus to the first invalid field.
  • Over-abstracting early: Start with a simple implementation, then extract a useWizard hook or Step component API later.

Testing the wizard

  • Unit test schemas with Zod’s safeParse
  • Component test each step with @testing-library/react:
    • Fill valid values, click Next, assert navigation
    • Leave a field invalid, click Next, assert error text and focused input
    • Refresh mid-flow, assert values persisted from localStorage
    • Submit, mock API and assert payload

Example snippet for a schema test:

import { describe, it, expect } from 'vitest';
import { z } from 'zod';

const Account = z.object({ email: z.string().email(), password: z.string().min(8) });

describe('Account schema', () => {
  it('accepts valid data', () => {
    const result = Account.safeParse({ email: 'a@b.com', password: 'password' });
    expect(result.success).toBe(true);
  });
});

Variations and extensions

  • Dynamic steps: Build the steps array from server config and map over it
  • Branching logic: Skip steps based on previous answers; ensure stepFields maps align with visible steps
  • Server-side validation: Run the same Zod schema in your API for isomorphic rules
  • Wizard as a component: Export to reuse across routes

Accessibility checklist

  • Every step has a clear heading and logical reading order
  • Error messages are programmatically linked via aria-describedby
  • Buttons have clear labels; don’t rely on color alone to indicate progress
  • Keyboard users can navigate Back/Next without traps
  • Avoid auto-focus jumps unless moving focus to the first invalid control

Deployment notes

  • If the wizard spans multiple routes, consider persisting through URL state or a global store
  • For large forms, code-split steps with React.lazy to reduce initial bundle
  • Enable HTTPS and Content Security Policy when collecting PII

Conclusion

You built a robust React multi-step wizard with React Hook Form and Zod: step-specific validation, resilient persistence, and strong accessibility. Use this as a template for onboarding flows, checkouts, and enterprise data entry—then extend it with dynamic steps, branching, and server-side validation as your product grows.

Related Posts