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.
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:
- Account: email, password
- Profile: first name, last name, birthday
- Address: country, city, postal code
- 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
React Hook Form Validation Tutorial: From Basics to Zod Schemas
Learn React Hook Form validation step by step—from register and Controller to type‑safe Zod schemas, async checks, and real‑world tips.
Build a Headless React UI Library: A Practical Tabs Tutorial
Build a headless React UI component library step by step—accessibility, state, TypeScript, testing, and packaging—using a fully featured Tabs example.
Build a GraphQL API and React Client: An End‑to‑End Tutorial
Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.