Introduction
Multi-step forms are everywhere in 2026 — onboarding flows, quote calculators, checkout sequences, lead capture wizards. Done well, they reduce cognitive load and significantly improve completion rates compared to single long forms. Done badly, they frustrate users and leak conversions.
In this tutorial, you'll build a production-ready multi-step form using React Hook Form v8 and Zod v4 — the dominant validation pairing in the React ecosystem right now. You'll handle per-step validation, persist state across steps, and wire up a clean progress indicator. No UI library required — just React, TypeScript, and two focused packages.
This pattern is used in client projects at Lenka Studio for SaaS onboarding and ecommerce checkout flows, and it scales cleanly across team codebases.
What You'll Need
- Node.js 20+ and a package manager (npm, pnpm, or bun)
- A React 19 project with TypeScript (Vite or Next.js 15 both work)
- Basic familiarity with React hooks and TypeScript generics
- Packages:
react-hook-form@^8,zod@^4,@hookform/resolvers@^4
Install the dependencies:
pnpm add react-hook-form zod @hookform/resolversStep 1: Define Your Schemas Per Step
The key architectural decision in a multi-step form is where validation lives. Rather than one giant schema, define a Zod schema for each step. This lets you validate only the current step's fields before allowing the user to advance.
Create a file called schemas.ts:
import { z } from 'zod';
export const step1Schema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email address'),
});
export const step2Schema = z.object({
company: z.string().min(1, 'Company name is required'),
country: z.string().min(1, 'Country is required'),
employees: z.enum(['1-10', '11-50', '51-200', '201+'], {
errorMap: () => ({ message: 'Select a company size' }),
}),
});
export const step3Schema = z.object({
plan: z.enum(['starter', 'growth', 'enterprise']),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
export const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
export type FormData = z.infer<typeof fullSchema>;Pro tip: Use .merge() to compose your full schema from step schemas. This gives you a single typed FormData type that covers all steps, which you'll use for persistent state.
Step 2: Set Up Persistent Form State
Multi-step forms need to remember previous steps. Use a useState object at the parent level to hold accumulated data, then pass partial values into each step's form instance.
Create MultiStepForm.tsx:
import { useState } from 'react';
import { FormData } from './schemas';
import { Step1 } from './steps/Step1';
import { Step2 } from './steps/Step2';
import { Step3 } from './steps/Step3';
import { ProgressBar } from './ProgressBar';
const TOTAL_STEPS = 3;
export function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<Partial<FormData>>({});
const handleNext = (stepData: Partial<FormData>) => {
const merged = { ...formData, ...stepData };
setFormData(merged);
setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
};
const handleBack = () => {
setCurrentStep((s) => Math.max(s - 1, 1));
};
const handleSubmit = (stepData: Partial<FormData>) => {
const finalData = { ...formData, ...stepData } as FormData;
console.log('Submitting:', finalData);
// Call your API here
};
return (
<div className='form-container'>
<ProgressBar current={currentStep} total={TOTAL_STEPS} />
{currentStep === 1 && (
<Step1 defaultValues={formData} onNext={handleNext} />
)}
{currentStep === 2 && (
<Step2 defaultValues={formData} onNext={handleNext} onBack={handleBack} />
)}
{currentStep === 3 && (
<Step3 defaultValues={formData} onSubmit={handleSubmit} onBack={handleBack} />
)}
</div>
);
}Common pitfall: Don't reset the entire form when moving between steps. Pass defaultValues into each step so users can go back and see their previously entered data intact.
Step 3: Build Each Step Component
Each step is a self-contained form with its own useForm instance, validated against its own Zod schema. Here's the pattern for Step 1:
Create steps/Step1.tsx:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { step1Schema } from '../schemas';
type Step1Data = z.infer<typeof step1Schema>;
interface Props {
defaultValues: Partial<Step1Data>;
onNext: (data: Step1Data) => void;
}
export function Step1({ defaultValues, onNext }: Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Step1Data>({
resolver: zodResolver(step1Schema),
defaultValues,
});
return (
<form onSubmit={handleSubmit(onNext)} noValidate>
<h2>Your Details</h2>
<div className='field'>
<label htmlFor='firstName'>First Name</label>
<input id='firstName' {...register('firstName')} />
{errors.firstName && (
<p role='alert' className='error'>{errors.firstName.message}</p>
)}
</div>
<div className='field'>
<label htmlFor='lastName'>Last Name</label>
<input id='lastName' {...register('lastName')} />
{errors.lastName && (
<p role='alert' className='error'>{errors.lastName.message}</p>
)}
</div>
<div className='field'>
<label htmlFor='email'>Email Address</label>
<input id='email' type='email' {...register('email')} />
{errors.email && (
<p role='alert' className='error'>{errors.email.message}</p>
)}
</div>
<button type='submit'>Next →</button>
</form>
);
}Repeat this pattern for Step 2 and Step 3, swapping in their respective schemas and fields. Step 3's onNext prop becomes onSubmit and triggers the final submission.
Pro tip: Always add noValidate to your <form> elements. This disables the browser's native validation UI and lets Zod + React Hook Form control all error messages consistently across browsers.
Step 4: Build the Progress Indicator
A visible progress bar is critical for form completion rates. Users need to know how far they've come and how much is left.
Create ProgressBar.tsx:
interface Props {
current: number;
total: number;
}
export function ProgressBar({ current, total }: Props) {
const percentage = Math.round((current / total) * 100);
return (
<div className='progress-wrapper' role='progressbar'
aria-valuenow={current}
aria-valuemin={1}
aria-valuemax={total}
aria-label={`Step ${current} of ${total}`}
>
<div className='progress-track'>
<div
className='progress-fill'
style={{ width: `${percentage}%`, transition: 'width 0.3s ease' }}
/>
</div>
<p className='progress-label'>Step {current} of {total}</p>
</div>
);
}Note the role='progressbar' and aria-* attributes — these make the indicator readable by screen readers, which matters for accessibility compliance in markets like Canada and Australia where WCAG conformance is increasingly expected by enterprise clients.
Step 5: Handle the Final Submission
When the user completes Step 3, you have a fully typed FormData object. Connect it to your API using fetch or a client like ky:
const handleSubmit = async (stepData: Partial<FormData>) => {
const finalData = { ...formData, ...stepData } as FormData;
try {
const response = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(finalData),
});
if (!response.ok) throw new Error('Submission failed');
// Navigate to success screen
} catch (err) {
console.error(err);
// Surface error to user
}
};Common pitfall: Don't submit on every step transition. Only call your API once, on the final step. Earlier steps should just merge into local state.
Step 6: Add URL-Based Step Persistence (Optional but Recommended)
For longer forms, reflect the current step in the URL using a query parameter (?step=2). This lets users bookmark mid-flow and prevents data loss on accidental refresh when combined with sessionStorage.
In Next.js 15, use the useSearchParams hook and router.push to sync step with the URL. In Vite + React Router 7, use useSearchParams from react-router. Store formData in sessionStorage and rehydrate on mount — a 20-line addition that significantly improves UX for complex flows.
Common Pitfalls to Avoid
- One global form instance: Using a single
useFormfor all steps makes selective per-step validation difficult. Per-step instances are cleaner. - Validating on every keystroke: Set
mode: 'onBlur'ormode: 'onSubmit'to avoid aggressive error messages while users are still typing. - Not labelling inputs: Every input needs an associated
<label>with a matchinghtmlFor/idpair. Placeholder text is not a substitute. - Forgetting mobile keyboards: Use
inputMode='email'andinputMode='numeric'on appropriate fields to trigger the right keyboard on iOS and Android.
Next Steps
You now have a fully functional, validated, accessible multi-step form built on the most reliable React form stack available in 2026. From here, you can extend it with animated step transitions using Motion (formerly Framer Motion), conditionally show or skip steps based on earlier answers, or integrate with tools like HubSpot or a Supabase backend.
If you're building a SaaS onboarding flow, a lead capture funnel, or a customer-facing wizard and want it built to production quality — the team at Lenka Studio works with SMBs across Australia, Singapore, Canada, and the US to ship exactly these kinds of experiences. Get in touch and tell us what you're building.




