Building a Multi-Step Form with React Hook Form and Zustand
#react
#forms
#zustand
#react-hook-form
#tutorial
Building a Multi-Step Form with React Hook Form and Zustand
Multi-step forms are essential for improving user experience when dealing with complex data collection. Breaking lengthy forms into smaller, digestible steps reduces cognitive load and increases completion rates. In this tutorial, we’ll build a robust multi-step form using React Hook Form for form management and validation, combined with Zustand for global state management.
Why This Stack?
React Hook Form provides excellent performance through uncontrolled components, minimal re-renders, and a simple API for form validation. It’s lightweight and integrates seamlessly with validation libraries like Zod or Yup.
Zustand offers a minimal, unopinionated state management solution that’s perfect for managing the current step and persisting form data across steps without the boilerplate of Redux or Context API.
Project Setup
First, install the necessary dependencies:
npm install react-hook-form zustand
npm install zod @hookform/resolvers
We’ll use Zod for schema validation, which provides excellent TypeScript support and runtime type safety.
Creating the Zustand Store
Let’s start by creating a store to manage our form state and navigation between steps:
import { create } from 'zustand';
interface FormData {
personalInfo: {
firstName: string;
lastName: string;
email: string;
};
address: {
street: string;
city: string;
zipCode: string;
country: string;
};
preferences: {
newsletter: boolean;
notifications: boolean;
};
}
interface FormStore {
currentStep: number;
formData: Partial<FormData>;
setCurrentStep: (step: number) => void;
updateFormData: (step: string, data: any) => void;
resetForm: () => void;
}
export const useFormStore = create<FormStore>((set) => ({
currentStep: 0,
formData: {},
setCurrentStep: (step) => set({ currentStep: step }),
updateFormData: (step, data) =>
set((state) => ({
formData: {
...state.formData,
[step]: data,
},
})),
resetForm: () => set({ currentStep: 0, formData: {} }),
}));
Defining Validation Schemas
Create validation schemas for each step using Zod:
import { z } from 'zod';
export const personalInfoSchema = z.object({
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'),
});
export const addressSchema = z.object({
street: z.string().min(5, 'Street address is required'),
city: z.string().min(2, 'City is required'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.string().min(2, 'Country is required'),
});
export const preferencesSchema = z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
});
Building Step Components
Now let’s create individual step components. Here’s the first step for personal information:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { personalInfoSchema } from './schemas';
import { useFormStore } from './store';
export function PersonalInfoStep() {
const { formData, updateFormData, setCurrentStep } = useFormStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(personalInfoSchema),
defaultValues: formData.personalInfo || {},
});
const onSubmit = (data) => {
updateFormData('personalInfo', data);
setCurrentStep(1);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<h2 className="text-2xl font-bold">Personal Information</h2>
<div>
<label htmlFor="firstName" className="block font-medium">
First Name
</label>
<input
{...register('firstName')}
className="w-full border rounded px-3 py-2"
/>
{errors.firstName && (
<p className="text-red-600 text-sm">{errors.firstName.message}</p>
)}
</div>
<div>
<label htmlFor="lastName" className="block font-medium">
Last Name
</label>
<input
{...register('lastName')}
className="w-full border rounded px-3 py-2"
/>
{errors.lastName && (
<p className="text-red-600 text-sm">{errors.lastName.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
{...register('email')}
type="email"
className="w-full border rounded px-3 py-2"
/>
{errors.email && (
<p className="text-red-600 text-sm">{errors.email.message}</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
Next
</button>
</form>
);
}
Creating the Main Form Container
The main container component orchestrates the steps and handles navigation:
import { useFormStore } from './store';
import { PersonalInfoStep } from './PersonalInfoStep';
import { AddressStep } from './AddressStep';
import { PreferencesStep } from './PreferencesStep';
const steps = [
{ component: PersonalInfoStep, title: 'Personal Info' },
{ component: AddressStep, title: 'Address' },
{ component: PreferencesStep, title: 'Preferences' },
];
export function MultiStepForm() {
const { currentStep, setCurrentStep } = useFormStore();
const CurrentStepComponent = steps[currentStep].component;
return (
<div className="max-w-2xl mx-auto p-6">
<div className="mb-8">
<div className="flex justify-between items-center">
{steps.map((step, index) => (
<div
key={index}
className={`flex-1 text-center ${
index <= currentStep ? 'text-blue-600' : 'text-gray-400'
}`}
>
<div
className={`w-10 h-10 mx-auto rounded-full flex items-center justify-center ${
index <= currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-300'
}`}
>
{index + 1}
</div>
<p className="mt-2 text-sm">{step.title}</p>
</div>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-8">
<CurrentStepComponent />
</div>
{currentStep > 0 && (
<button
onClick={() => setCurrentStep(currentStep - 1)}
className="mt-4 text-blue-600 hover:underline"
>
Back
</button>
)}
</div>
);
}
Handling Form Submission
The final step component handles the complete form submission:
export function PreferencesStep() {
const { formData, updateFormData, resetForm } = useFormStore();
const {
register,
handleSubmit,
} = useForm({
resolver: zodResolver(preferencesSchema),
defaultValues: formData.preferences || {
newsletter: false,
notifications: false,
},
});
const onSubmit = async (data) => {
updateFormData('preferences', data);
const completeFormData = {
...formData,
preferences: data,
};
try {
const response = await fetch('/api/submit-form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(completeFormData),
});
if (response.ok) {
alert('Form submitted successfully!');
resetForm();
}
} catch (error) {
console.error('Submission error:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<h2 className="text-2xl font-bold">Preferences</h2>
<div className="flex items-center space-x-2">
<input
{...register('newsletter')}
type="checkbox"
id="newsletter"
/>
<label htmlFor="newsletter">Subscribe to newsletter</label>
</div>
<div className="flex items-center space-x-2">
<input
{...register('notifications')}
type="checkbox"
id="notifications"
/>
<label htmlFor="notifications">Enable notifications</label>
</div>
<button
type="submit"
className="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700"
>
Submit
</button>
</form>
);
}
Key Benefits of This Approach
Performance: React Hook Form minimizes re-renders by using uncontrolled components, making the form extremely performant even with many fields.
Type Safety: Using TypeScript with Zod schemas provides end-to-end type safety from validation to state management.
User Experience: Form data persists across steps, allowing users to navigate back and forth without losing information.
Scalability: Adding new steps is straightforward—just create a new component with its schema and add it to the steps array.
Minimal Boilerplate: Zustand’s simple API means less code compared to traditional Redux patterns.
Advanced Enhancements
Consider these improvements for production applications:
- Implement local storage persistence to save progress across browser sessions
- Add loading states and error boundaries for better error handling
- Create a progress bar showing completion percentage
- Implement conditional steps based on previous answers
- Add animations between step transitions using libraries like Framer Motion
Conclusion
Combining React Hook Form with Zustand creates a powerful, performant solution for multi-step forms. React Hook Form handles the complex validation logic while Zustand manages the application state with minimal overhead. This architecture scales well and provides an excellent developer experience with strong TypeScript support.
The pattern demonstrated here can be adapted for various use cases, from onboarding flows to complex data collection forms, making it a valuable addition to any React developer’s toolkit.