Building Multi-Step Form Wizards with React Hook Form and Zod
Quick Answer
Build multi-step forms with React Hook Form by using a single form instance with defaultValues, validate specific fields per step with form.trigger(), manage step state separately, and optionally persist data to localStorage using Zustand. Each step validates only its own fields before allowing progression.
When to Use Multi-Step Forms
Multi-step forms improve user experience for complex data collection by breaking long forms into manageable chunks. Consider using them when:
- Registration flows - Collecting user profile, contact info, and preferences
- Application forms - Scholarship, job, or membership applications
- Event planning - Booking details, item selection, and confirmation
- Onboarding wizards - Sequential setup steps for new users
- E-commerce checkouts - Cart, shipping, payment, and review
The key is balancing simplicity with completeness. A form with 5-10 fields might work better as a single page, but 20+ fields definitely benefit from segmentation.
Real-World Example
We built a user registration system for a web application that collects:
- Personal information (8 fields)
- Address details (7 fields)
- Contact and preferences (6 fields)
- Document uploads (5 fields)
- Review and submit
This would be overwhelming as a single form. Breaking it into 5 steps reduced abandonment by 40%.
Setting Up React Hook Form with Zod
First, install the dependencies:
npm install react-hook-form zod @hookform/resolvers
The foundation of a multi-step form is a single form instance that manages all fields across all steps:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Combined schema for all steps
const completeFormSchema = z.object({
// Step 1 fields
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
// Step 2 fields
address: z.string().min(5, 'Address is required'),
city: z.string().min(2, 'City is required'),
zipCode: z.string().regex(/^\d{5}$/, 'ZIP code must be 5 digits'),
// Step 3 fields
phoneNumber: z.string().regex(/^\+?[1-9]\d{9,14}$/, 'Invalid phone number'),
agreeToTerms: z.boolean().refine(val => val === true, {
message: 'You must agree to the terms'
})
});
type FormData = z.infer<typeof completeFormSchema>;
function RegistrationWizard() {
const form = useForm<FormData>({
resolver: zodResolver(completeFormSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
address: '',
city: '',
zipCode: '',
phoneNumber: '',
agreeToTerms: false
},
mode: 'onChange' // Validate as user types
});
// Step management comes next...
}
Why a Single Form Instance?
Using one useForm instance for all steps provides:
- Unified state - All form data lives in one place
- Proper validation - Zod sees all fields for complex cross-field rules
- Easier submission - One
handleSubmitfor the entire form - Better performance - No re-mounting form instances between steps
Managing Form State Across Steps
The simplest step management uses useState:
'use client';
import { useState } from 'react';
const TOTAL_STEPS = 3;
function RegistrationWizard() {
const [currentStep, setCurrentStep] = useState(1);
const form = useForm<FormData>({
// ... form config
});
const handleNext = async () => {
// Validate current step before advancing
const stepFields = getStepFields(currentStep);
const isValid = await form.trigger(stepFields);
if (isValid) {
setCurrentStep(prev => Math.min(prev + 1, TOTAL_STEPS));
}
};
const handlePrevious = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
return (
<div>
{/* Progress indicator */}
<ProgressIndicator current={currentStep} total={TOTAL_STEPS} />
{/* Render current step */}
{currentStep === 1 && <PersonalInfoStep />}
{currentStep === 2 && <AddressStep />}
{currentStep === 3 && <ReviewStep />}
{/* Navigation */}
<div className="flex justify-between mt-6">
<button
type="button"
onClick={handlePrevious}
disabled={currentStep === 1}
>
Previous
</button>
{currentStep < TOTAL_STEPS ? (
<button type="button" onClick={handleNext}>
Next
</button>
) : (
<button type="submit" onClick={form.handleSubmit(onSubmit)}>
Submit
</button>
)}
</div>
</div>
);
}
Custom Hook for Step Management
For reusability, extract step logic into a custom hook:
// hooks/use-multistep-form.ts
import { ReactElement, useCallback, useState } from 'react';
export function useMultistepForm(steps: ReactElement[]) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const next = useCallback(() => {
setCurrentStepIndex(i => Math.min(i + 1, steps.length - 1));
}, [steps.length]);
const back = useCallback(() => {
setCurrentStepIndex(i => Math.max(i - 1, 0));
}, []);
const goTo = useCallback((index: number) => {
setCurrentStepIndex(index);
}, []);
return {
currentStepIndex,
step: steps[currentStepIndex],
steps,
isFirstStep: currentStepIndex === 0,
isLastStep: currentStepIndex === steps.length - 1,
goTo,
next,
back
};
}
// Usage:
const steps = [
<PersonalInfoStep key="personal" />,
<AddressStep key="address" />,
<ReviewStep key="review" />
];
const { step, isFirstStep, isLastStep, next, back } = useMultistepForm(steps);
Per-Step Validation with Zod
The key to multi-step forms is validating only the current step's fields before allowing progression.
Defining Step Schemas
Break your schema into logical sections:
// Step 1: Personal Information
export const personalInfoSchema = z.object({
firstName: z.string()
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name must not exceed 50 characters')
.regex(
/^[a-zA-Z\s'-]+$/,
'First name can only contain letters, spaces, hyphens, and apostrophes'
),
lastName: z.string()
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name must not exceed 50 characters'),
birthDate: z.date({
required_error: 'Birth date is required',
invalid_type_error: 'Invalid date format'
}).refine(
(date) => {
const age = calculateAge(date);
return age >= 15 && age <= 30;
},
{ message: 'Must be between 15-30 years old' }
),
email: z.string()
.email('Invalid email address')
.max(100, 'Email must not exceed 100 characters')
});
// Step 2: Address Information
export const addressInfoSchema = z.object({
streetAddress: z.string()
.min(5, 'Street address is required')
.max(200, 'Street address is too long'),
city: z.string().min(2, 'City is required'),
zipCode: z.string().regex(/^\d{4,5}$/, 'ZIP code must be 4-5 digits'),
phoneNumber: z.string()
.regex(/^\+?[1-9]\d{9,14}$/, 'Invalid phone number format')
});
// Step 3: Document Upload
export const documentUploadSchema = z.object({
idType: z.string().min(1, 'ID type is required'),
frontIdImage: z
.instanceof(File, { message: 'Front ID image is required' })
.refine(file => file.size <= 5 * 1024 * 1024, {
message: 'File size must not exceed 5MB'
})
.refine(
file => ['image/jpeg', 'image/png', 'image/jpg'].includes(file.type),
{ message: 'File must be JPEG or PNG' }
)
});
// Combine all schemas
export const completeFormSchema = personalInfoSchema
.merge(addressInfoSchema)
.merge(documentUploadSchema);
export type FormData = z.infer<typeof completeFormSchema>;
Validating Specific Step Fields
Create a helper function that returns field names for each step:
function getStepFields(step: number): (keyof FormData)[] {
switch (step) {
case 1: // Personal Information
return ['firstName', 'lastName', 'birthDate', 'email'];
case 2: // Address Information
return ['streetAddress', 'city', 'zipCode', 'phoneNumber'];
case 3: // Document Upload
return ['idType', 'frontIdImage'];
default:
return [];
}
}
Use form.trigger() to validate only those fields:
const handleNext = async () => {
const stepFields = getStepFields(currentStep);
const isValid = await form.trigger(stepFields);
if (isValid) {
// Save draft (optional)
saveDraft(form.getValues(), currentStep);
// Move to next step
setCurrentStep(prev => prev + 1);
} else {
// Errors will show automatically via form state
console.log('Validation failed for step', currentStep);
}
};
Real-World Example: Resource Request Form
Here's a production form for a resource request system:
'use client';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const STEPS = [
{ id: 1, title: 'Request Details' },
{ id: 2, title: 'Select Resources' },
{ id: 3, title: 'Review & Submit' }
];
export default function ResourceRequestForm() {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<ResourceRequestFormData>({
resolver: zodResolver(resourceRequestSchema),
defaultValues: {
requestTitle: '',
requestDate: undefined,
purpose: '',
items: []
},
mode: 'onChange'
});
// ... handleNext, handlePrevious similar to previous examples
const handleSubmit = async (data: ResourceRequestFormData) => {
if (currentStep !== STEPS.length) return;
// ... submission logic
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<ProgressIndicator steps={STEPS} current={currentStep} />
{/* Step content and navigation */}
</form>
</FormProvider>
);
}
function getStepFields(step: number): string[] {
const fieldsByStep: Record<number, string[]> = {
1: ['requestTitle', 'requestDate', 'purpose'],
2: ['items'],
3: [] // Review step has no new fields
};
return fieldsByStep[step] || [];
}
Progress Indicators and Navigation
A clear progress indicator improves UX significantly. Here's an accessible implementation:
interface ProgressIndicatorProps {
steps: { id: number; title: string }[];
current: number;
}
export function ProgressIndicator({ steps, current }: ProgressIndicatorProps) {
return (
<div className="mb-8">
<div className="relative flex items-center justify-between">
{steps.map((step, index) => (
<div key={step.id} className="relative z-10 flex flex-1 flex-col items-center">
{/* Connector line between steps */}
{index < steps.length - 1 && (
<div
className={`absolute top-5 left-1/2 -z-10 h-0.5 w-full ${
current > step.id ? 'bg-blue-600' : 'bg-gray-200'
}`}
aria-hidden="true"
/>
)}
{/* Step circle with ARIA attributes */}
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
current >= step.id ? 'bg-blue-600 text-white' : 'bg-gray-200'
}`}
aria-label={`Step ${step.id}: ${step.title}`}
aria-current={current === step.id ? 'step' : undefined}
>
{step.id}
</div>
<p className="mt-2 text-center text-xs sm:text-sm">{step.title}</p>
</div>
))}
</div>
</div>
);
}
Keyboard Navigation
Make forms keyboard-accessible:
const handleKeyDown = (e: React.KeyboardEvent) => {
// Allow Enter to advance (but not in textareas)
if (
e.key === 'Enter' &&
e.target instanceof HTMLInputElement &&
currentStep < STEPS.length
) {
e.preventDefault();
handleNext();
}
// Allow Escape to go back
if (e.key === 'Escape' && currentStep > 1) {
e.preventDefault();
handlePrevious();
}
};
return (
<div onKeyDown={handleKeyDown}>
{/* Form content */}
</div>
);
Persisting Data Between Steps
Use localStorage or a state management library to prevent data loss.
Option 1: localStorage with Zustand
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface DraftState {
drafts: Record<string, { data: Record<string, any>; step?: number; lastSaved: number }>;
saveDraft: (type: string, data: Record<string, any>, step?: number) => void;
getDraft: (type: string) => { data: Record<string, any>; step?: number } | null;
clearDraft: (type: string) => void;
}
export const useDraftStore = create<DraftState>()(
persist(
(set, get) => ({
drafts: {},
saveDraft: (type, data, step) => {
set(state => ({
drafts: { ...state.drafts, [type]: { data, step, lastSaved: Date.now() } }
}));
},
getDraft: (type) => get().drafts[type] || null,
clearDraft: (type) => {
set(state => {
const { [type]: _, ...remaining } = state.drafts;
return { drafts: remaining };
});
}
}),
{ name: 'form-drafts' }
)
);
Usage in your form:
function RegistrationWizard() {
const { saveDraft, getDraft, clearDraft } = useDraftStore();
// Load draft on mount
useEffect(() => {
const draft = getDraft('registration');
if (draft) {
// Restore form data
form.reset(draft.data);
// Restore step
if (draft.step) setCurrentStep(draft.step);
}
}, []);
// Auto-save on step change
const handleNext = async () => {
const stepFields = getStepFields(currentStep);
const isValid = await form.trigger(stepFields);
if (isValid) {
// Save draft
saveDraft('registration', form.getValues(), currentStep + 1);
setCurrentStep(prev => prev + 1);
}
};
// Clear draft on successful submission
const handleSubmit = async (data: FormData) => {
try {
await submitForm(data);
clearDraft('registration'); // Clear saved draft
router.push('/success');
} catch (error) {
console.error(error);
}
};
}
Option 2: Simple localStorage
const DRAFT_KEY = 'registration-draft';
function RegistrationWizard() {
// Load draft
useEffect(() => {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) {
try {
const { data, step } = JSON.parse(saved);
form.reset(data);
setCurrentStep(step);
} catch (error) {
console.error('Failed to parse draft:', error);
}
}
}, []);
// Save on every change
useEffect(() => {
const subscription = form.watch((values) => {
localStorage.setItem(
DRAFT_KEY,
JSON.stringify({
data: values,
step: currentStep,
lastSaved: Date.now()
})
);
});
return () => subscription.unsubscribe();
}, [currentStep, form]);
// Clear on submit
const handleSubmit = async (data: FormData) => {
try {
await submitForm(data);
localStorage.removeItem(DRAFT_KEY);
router.push('/success');
} catch (error) {
console.error(error);
}
};
}
Handling File Uploads in Wizards
File uploads require special handling in multi-step forms.
File Upload Step Component
import { useFormContext } from 'react-hook-form';
import { useState } from 'react';
export function DocumentUploadStep() {
const { register, setValue, formState: { errors } } = useFormContext();
const [preview, setPreview] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setValue('document', file, { shouldValidate: true });
// Create preview
const reader = new FileReader();
reader.onloadend = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
};
return (
<div className="space-y-6">
<div>
<label htmlFor="documentType" className="block font-medium mb-2">
Document Type <span className="text-red-500">*</span>
</label>
<select
id="documentType"
{...register('documentType')}
className="w-full border rounded p-2"
aria-required="true"
aria-invalid={!!errors.documentType}
>
<option value="">Select type</option>
<option value="id">ID Document</option>
<option value="certificate">Certificate</option>
</select>
{/* ... error display */}
</div>
<div>
<label htmlFor="document" className="block font-medium mb-2">
Upload Document <span className="text-red-500">*</span>
</label>
<input
id="document"
type="file"
accept="image/jpeg,image/png"
onChange={handleFileChange}
aria-required="true"
aria-invalid={!!errors.document}
/>
{preview && <img src={preview} alt="Preview" className="mt-3 max-w-xs" />}
</div>
</div>
);
}
File Upload Submission
When submitting, use FormData for file uploads:
const handleSubmit = async (data: FormData) => {
try {
setIsSubmitting(true);
const formData = new FormData();
formData.append('firstName', data.firstName);
formData.append('lastName', data.lastName);
// ... other text fields
if (data.document) {
formData.append('document', data.document);
}
const response = await fetch('/api/registration', {
method: 'POST',
body: formData // Don't set Content-Type header
});
if (!response.ok) throw new Error('Submission failed');
router.push('/success');
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
Accessibility Considerations
Multi-step forms must be accessible to all users.
Focus Management
Move focus to the start of each step:
import { useRef, useEffect } from 'react';
function RegistrationWizard() {
const stepContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Focus step container when step changes
stepContainerRef.current?.focus();
}, [currentStep]);
return (
<div
ref={stepContainerRef}
tabIndex={-1}
aria-label={`Step ${currentStep} of ${STEPS.length}`}
className="outline-none"
>
{/* Step content */}
</div>
);
}
Announcing Step Changes
Use a live region to announce step transitions:
function RegistrationWizard() {
const [announcement, setAnnouncement] = useState('');
const handleNext = async () => {
const isValid = await form.trigger(getStepFields(currentStep));
if (isValid) {
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
setAnnouncement(`Now on step ${nextStep}: ${STEPS[nextStep - 1].title}`);
} else {
setAnnouncement('Please correct the errors before continuing');
}
};
return (
<>
{/* Live region for screen readers */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
{/* Form content */}
</>
);
}
Semantic HTML
Use proper ARIA attributes:
<form onSubmit={form.handleSubmit(handleSubmit)}>
<fieldset>
<legend className="text-lg font-semibold mb-4">
Step {currentStep}: {STEPS[currentStep - 1].title}
</legend>
{/* Step content */}
</fieldset>
{/* Navigation */}
<div role="group" aria-label="Form navigation">
<button type="button" onClick={handlePrevious}>
Previous
</button>
<button type="button" onClick={handleNext}>
Next
</button>
</div>
</form>
Complete Working Example
Here's a condensed implementation combining all concepts:
'use client';
import { useState, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const completeSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
streetAddress: z.string().min(5),
city: z.string().min(2),
zipCode: z.string().regex(/^\d{5}$/)
});
type FormData = z.infer<typeof completeSchema>;
const STEPS = [
{ id: 1, title: 'Personal Information' },
{ id: 2, title: 'Address' },
{ id: 3, title: 'Review' }
];
export default function MultiStepRegistration() {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(completeSchema),
defaultValues: { firstName: '', lastName: '', email: '', streetAddress: '', city: '', zipCode: '' },
mode: 'onChange'
});
// Auto-save to localStorage
useEffect(() => {
const subscription = form.watch((values) => {
localStorage.setItem('registration-draft', JSON.stringify({ data: values, step: currentStep }));
});
return () => subscription.unsubscribe();
}, [currentStep, form]);
const handleNext = async () => {
const stepFields = getStepFields(currentStep);
if (await form.trigger(stepFields)) {
setCurrentStep(prev => Math.min(prev + 1, STEPS.length));
}
};
// ... handlePrevious, handleSubmit similar to previous examples
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<ProgressIndicator steps={STEPS} current={currentStep} />
{currentStep === 1 && <PersonalInfoStep />}
{currentStep === 2 && <AddressStep />}
{currentStep === 3 && <ReviewStep />}
{/* Navigation buttons */}
</form>
</FormProvider>
);
}
function getStepFields(step: number): (keyof FormData)[] {
switch (step) {
case 1: return ['firstName', 'lastName', 'email'];
case 2: return ['streetAddress', 'city', 'zipCode'];
default: return [];
}
}
Best Practices and Tips
1. Validate Early and Often
Use mode: 'onChange' to provide immediate feedback:
const form = useForm({
resolver: zodResolver(schema),
mode: 'onChange' // Validate as user types
});
2. Provide Clear Error Messages
const schema = z.object({
email: z.string()
.min(1, 'Email is required') // Empty field
.email('Please enter a valid email address'), // Invalid format
phoneNumber: z.string()
.regex(
/^\+?[1-9]\d{9,14}$/,
'Phone number must be 10-15 digits, optionally starting with +'
)
});
3. Show Progress
Always show users where they are:
<p className="text-sm text-gray-600 mb-4">
Step {currentStep} of {STEPS.length}
</p>
4. Allow Going Back
Users should be able to review and edit previous steps:
const goToStep = (stepNumber: number) => {
if (stepNumber <= currentStep || stepNumber === currentStep - 1) {
setCurrentStep(stepNumber);
}
};
5. Auto-Save Drafts
Prevent data loss with auto-save:
useEffect(() => {
const interval = setInterval(() => {
const values = form.getValues();
saveDraft('registration', values, currentStep);
}, 30000); // Save every 30 seconds
return () => clearInterval(interval);
}, [currentStep, form]);
6. Disable Submit Until Final Step
{currentStep === STEPS.length ? (
<button type="submit">Submit</button>
) : (
<button type="button" onClick={handleNext}>Next</button>
)}
Conclusion
Multi-step forms improve UX for complex data collection when implemented correctly. Key takeaways:
- Use a single form instance with React Hook Form for all steps
- Validate per-step with
form.trigger()for specific fields - Manage step state separately from form state
- Persist data to localStorage or a state management library
- Handle file uploads with FormData and proper previews
- Ensure accessibility with ARIA, focus management, and live regions
The approach shown here scales from simple 2-step forms to complex 10+ step wizards. By combining React Hook Form's powerful validation with Zod's type safety, you get a robust foundation for any multi-step form.
See It in Action
This article was based on real implementations from client projects, including governance platforms and enterprise applications. We have implemented these patterns across various production systems featuring:
- User registration flows (5 steps)
- Resource request forms (3 steps)
- Application form wizards (4 steps)
Read our case studies to see how we implemented these patterns in production applications.
Content Upgrade
Complete SaaS Development Checklist
Download our comprehensive 50-point checklist to build your SaaS product right the first time.
Download Free ChecklistFrootsyTech Solutions
Expert Software Development Team
Enterprise Software Development, Cloud Architecture, Full-Stack Engineering
FrootsyTech Solutions is an agile, expert-led software development agency specializing in web and mobile applications. Our team brings decades of combined experience in building scalable, production-ready solutions for businesses worldwide.
Related Articles
Generating PDFs, Excel, and PowerPoint Reports in Next.js
Complete guide to implementing multi-format report exports in Next.js applications. Learn to generate PDFs with charts, Excel workbooks with formatting, and PowerPoint presentations using client and server-side rendering.
Getting Started with Next.js 15: A Comprehensive Guide
Learn how to build modern web applications with Next.js 15, the React framework that makes building full-stack applications easier than ever.
Advanced TypeScript Patterns for Better Code Quality
Master advanced TypeScript patterns including conditional types, mapped types, and type guards to write more maintainable and type-safe code.