React Hook Form Multi-Step Tutorial: Zustand + Zod + Shadcn
Complete guide to building production-ready React multi-step forms with validation, state management, and conditional logic

⚛️ Advanced React Development Guides
Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.
Related Posts:
- •The Invisible Form Bug: React 19 + React Hook Form's Hidden Compatibility Issue
- •How To Debug React Hook Form Errors: Use Simple useEffect
- •Building a Newsletter Form in Next.js 15 with React 19, React Hook Form, and Shadcn UI
- •My Approach to Building Forms with useActionState and Server Actions in Next.js 15
I recently got tasked to create a multi-step form for a client using React Hook Form, Zustand, and Zod. In this React multi-step form tutorial, you'll discover how to build a production-ready multi-step form with React Hook Form for validation, Zustand for state management, and Zod for schema validation.
This guide covers everything: planning your form structure, implementing conditional logic, managing state persistence across steps, and integrating Shadcn UI components for a polished interface. Whether you're building a survey, checkout flow, or onboarding wizard, this React Hook Form multi-step pattern scales perfectly.
We'll walk through the implementation step by step: Scoping the Form, Setting Up State Management, Building Custom Hooks, Creating Step Components, Implementing Conditional Navigation, and Tying Everything Together. Plus, you'll get a working demo and GitHub repository to explore.
The Problem with Multi-Step Forms
When I checked some of my favourite libraries — React Hook Form, Shadcn, Headless UI, and Material UI — none of them had out-of-the-box solutions for multi-step logic. I couldn't find anything out there.
When searching for solutions like "React Hook Form multi-step" or "React Hook Form multistep," you'll find limited official documentation. React Hook Form excels at single forms, but doesn't provide built-in patterns for managing state across multiple steps. This is why pairing it with Zustand for state management and Zod for validation creates such a powerful combination.
Single forms in React are straightforward, but multi-step forms? That's where things get tricky.
If you encounter issues with React Hook Form during development, I recommend reading how to debug React Hook Form errors using useEffect, which has saved me countless hours when validation isn't working as expected. Additionally, be aware of React 19's compatibility issue with React Hook Form's watch() method if you're using React 19.
I decided to give it a go myself, and here is my approach to help anyone who wants to build multi-step forms in the future.
Step 1: Scoping the Form
Before we start writing code, we need to define each step in advance. Meticulous planning up front saves debugging later.
This includes listing all the options for each step and mapping out any conditional logic. If some selections dynamically trigger additional options within the same step, those conditions must be planned beforehand. Likewise, if a specific choice requires skipping a future step, that logic should be outlined upfront. The secret to a smooth user experience is meticulous planning — the more thought you put in now, the less time you'll spend fixing issues later.
I cannot stress this enough, especially with the rise of AI in coding. If you do not plan it and know what you want beforehand, you will end up with code that follows different logic in each component and spend more time deleting and debugging than anything else.
Defining the Steps
We are building a skincare routine builder form. Let's think about the steps it will include, one by one. Consider what information we need, what details are useful, and what data we must collect to suggest the best possible routine for the user. It all comes down to understanding the problem inside and out.
Welcome Screen — Introduces the form and explains the purpose of collecting skincare-related data.
Select Skin Type — The user identifies their skin type (e.g., dry, oily, combination), which helps personalize recommendations.
Select Skin Goals — Users choose their skincare goals, such as hydration, anti-ageing, or acne control. If acne is selected, they are prompted to specify the acne type (Hormonal, Stress-Related, or Congestion).
Age Group — The user selects their age range to further refine skincare suggestions.
Environmental Factors — This step collects information about exposure to environmental stressors like pollution and sun damage.
Lifestyle Factors — Users provide insights into their habits, such as diet, stress levels, and sleep patterns, which influence skin health.
Exfoliation Tolerance — Determines how often and what type of exfoliation the user prefers. If anything but "Never" is selected, additional options appear for choosing between Physical Scrubs, Chemical Exfoliants, or Enzyme Exfoliators.
Ingredient Preferences — Users can specify preferred or avoided ingredients, such as fragrance-free or natural products.
Routine Complexity — Allows users to choose between a simple or multi-step skincare regimen.
Budget Allocation — Determines how much the user is willing to spend on skincare products.
Ethical Preferences — Collects data on ethical considerations, such as cruelty-free or vegan skincare preferences.
Makeup Question — Asks about the user's makeup habits to refine skincare recommendations.
Final Step — Concludes the form, possibly summarizing responses and suggesting suitable skincare routines.
Conditional Logic
The form incorporates conditional logic to streamline the user experience. For example, if a user selects Acne as a skincare concern, they must specify the acne type before proceeding. If the user expresses interest in exfoliation, they must select the preferred exfoliation method(s). The form dynamically adapts to each user's responses, ensuring only relevant questions are displayed.
On top of that, multi-step forms bring several challenges: users may abandon longer forms halfway, switching tabs or losing connection shouldn't result in losing filled data, and conditional logic for rendering relevant steps based on user input complicates things further.
Step 2: Tech Stack
Having the right tools is the foundation of a great developer experience. Here's the tech stack we'll use:
React Hook Form — Well-documented, battle-tested form management and validation.
Tailwind CSS — For styling — nothing more is needed.
Zustand — For managing form state across steps and sessions — super simple and lightweight state manager.
Zod — A no-brainer for schema-based validation — natively integrates with RHF.
Shadcn UI — The current superstar in the UI components world.
Understanding Zod vs Zustand: Why We Need Both
I often see confusion about "Zod vs Zustand"—developers asking which one to use for forms. The answer is both, because they solve different problems:
Zustand: State Management
Purpose: Store and persist data across components and sessions
Use in forms: Keeps form data accessible across steps, survives page refreshes via localStorage
Zustand manages where your data lives. In our multi-step form, Zustand stores:
- Current step number
- All form responses from previous steps
- Progress state for navigation
If you're working with Next.js and need to initialize Zustand with server data, I've covered strategies for passing initial props to Zustand.
Zod: Validation & Type Safety
Purpose: Define data schemas and validate user input
Use in forms: Ensures each step's data meets requirements before proceeding
Zod manages what your data should look like. In our multi-step form, Zod:
- Validates each field against defined rules
- Provides TypeScript types automatically
- Creates clear error messages for users
Why Not Just One?
You can't replace Zustand with Zod or vice versa:
Without Zustand: Your form data disappears when moving between steps or refreshing. React Hook Form only manages the current step.
Without Zod: You lose type safety, validation schema clarity, and automatic TypeScript inference. Manual validation is error-prone.
The Power of Combining Both
When you use Zod's zodResolver with React Hook Form, you get validated data that Zustand can safely store. This creates a robust pipeline:
User Input → Zod Validates → React Hook Form Handles → Zustand Stores → localStorage Persists
This pattern isn't just for multi-step forms—it works for any complex form requiring state persistence and strong validation.
Implementation Decision: One Form Instance Per Step
For simplicity, we'll treat each step as an independent form. This keeps the form logic lightweight and simplifies the overall flow. The alternative is to create one form instance of RHF and make it so all steps belong to it.
Benefits of using multiple instances approach:
- Validation per step and type safety: Each step has its validation, ensuring the data is validated as the user progresses.
- Separation of concerns: Zustand handles state management, while React Hook Form takes care of the form logic.
- Immediate validation: Each form validates step-by-step.
- Error localization: Errors won't affect unrelated steps.
- Reusable architecture: Steps can be reused independently.
Downsides:
- Code duplication
- Increased bundle size
- State synchronization complexity
The main question is: do these downsides outweigh the benefits for your use case? For multi-step forms requiring validation, the advantages — such as type safety, validation, and state persistence — typically outweigh the drawbacks, especially with mitigation strategies in place.
Step 3: Setting Up Zustand for State Management
Now we'll create the store for our multi-step form. Make sure you have Zustand installed via npm i zustand.
// File: src/lib/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface FormState {
currentStep: number;
formData: Record<string, any>;
setFormData: (data: Record<string, any>) => void;
setCurrentStep: (step: number) => void;
resetForm: () => void;
getLatestState: () => FormState;
}
export const useFormStore = create<FormState>()(
persist(
(set, get) => ({
currentStep: 0,
formData: {},
setFormData: (data) =>
set((state) => ({
formData: { ...state.formData, ...data },
})),
setCurrentStep: (step) => set({ currentStep: step }),
resetForm: () =>
set({
currentStep: 0,
formData: {},
}),
getLatestState: () => get(),
}),
{
name: 'skincare-form-storage',
}
)
);
Here's how this store works: Persistent Storage uses Zustand's persist middleware to save form progress to localStorage. Progressive Updates allow partial form data merging on each step. Form Reset clears all data when needed.
Step 4: Custom Hook to Manage Form State Per Step
As a good developer, we need to reduce the repetition of information that is likely to change, replacing it with abstractions that are less prone to modification. To avoid code duplication, we'll create a custom hook to efficiently handle form state management across each step.
This hook integrates React Hook Form with Zustand, streamlining form initialization, validation, and step-by-step navigation.
Create a new file src/hooks/use-form-step.ts:
// File: src/hooks/use-form-step.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ZodSchema } from 'zod';
import { useFormStore } from '@/lib/store';
export const useFormStep = (
schema: ZodSchema,
currentStep: number,
totalSteps: number
) => {
const { formData, setFormData, setCurrentStep, getLatestState } =
useFormStore();
const defaultValues = Object.keys(schema.shape).reduce(
(acc, key) => {
acc[key] = formData[key] || '';
return acc;
},
{} as Record<string, any>
);
const form = useForm({
resolver: zodResolver(schema),
defaultValues,
mode: 'onBlur',
});
const handleNext = (data: any) => {
setFormData(data);
if (currentStep < totalSteps - 1) {
setCurrentStep(currentStep + 1);
}
};
const handleNextOverride = (step: number, data: any) => {
setFormData(data);
setCurrentStep(step);
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
return {
form,
handleNext,
handleNextOverride,
handleBack,
getLatestState,
};
};
This hook manages form state across steps, handles Zod validation, enables step navigation, and persists data with Zustand.
Creating a TypeScript Interface
To gain the benefits of type safety in TypeScript, we'll define a SkincareFormData interface. This type will be used by the Zustand store, the step components, and the custom hook to ensure consistency and prevent errors during development.
Create a new file src/types/global.d.ts:
// File: src/types/global.d.ts
export interface SkincareFormData {
skinType?: string;
skinGoals?: string[];
acneType?: string;
ageGroup?: string;
environmentalFactors?: string[];
lifestyleFactors?: string[];
exfoliationTolerance?: string;
exfoliationType?: string[];
ingredientPreferences?: string[];
routineComplexity?: string;
budget?: string;
ethicalPreferences?: string[];
makeupHabits?: string;
}
Step 5: Creating a Regular Step Component
Now, let's focus our attention on creating the first step in our multi-step form. We will ask the user to select their skin type. We will use Shadcn UI components to create a beautiful UI that matches the beauty theme.
Create a new file src/components/steps/step-1.tsx:
// File: src/components/steps/step-1.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { useFormStep } from '@/hooks/use-form-step';
const schema = z.object({
skinType: z.string().min(1, 'Please select a skin type'),
});
const skinTypes = [
{ value: 'dry', label: 'Dry' },
{ value: 'oily', label: 'Oily' },
{ value: 'combination', label: 'Combination' },
{ value: 'sensitive', label: 'Sensitive' },
];
export const Step1 = ({ onNext }: { onNext: (data: any) => void }) => {
const { form, handleNext } = useFormStep(schema, 0, 13);
const onSubmit = (data: any) => {
handleNext(data);
onNext(data);
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Choose Your Skin Type</h2>
<p className="text-gray-600">This helps us personalize recommendations</p>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{skinTypes.map((type) => (
<button
key={type.value}
type="button"
onClick={() => form.setValue('skinType', type.value)}
className={`p-4 border rounded-lg transition ${
form.watch('skinType') === type.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
}`}
>
{type.label}
</button>
))}
</div>
{form.formState.errors.skinType && (
<p className="text-red-500">{form.formState.errors.skinType.message}</p>
)}
<div className="flex justify-between pt-4">
<Button variant="outline" disabled>
Back
</Button>
<Button type="submit">Next</Button>
</div>
</form>
</div>
);
};
This step component uses schema validation to create a form validation schema for this particular step using Zod, defines options for the user to choose from in skinTypes, calls our custom-created hook from the previous step, and provides buttons for continuing and going back — only if the form is valid according to schema will the user be allowed to move forward.
Step 6: Creating a Step Component with Conditional Navigation
Let's consider a case where we want to skip certain steps based on user choices. For example, in the Routine Complexity step, if the user selects "Minimal", we assume they are only interested in basic skincare and will skip the Budget Allocation step. Instead of proceeding to step 10 (Budget Allocation), the form jumps directly to step 11 (Ethical Preferences) since budget considerations are irrelevant for minimal routines.
Create a new file src/components/steps/step-routine-complexity.tsx:
// File: src/components/steps/step-routine-complexity.tsx
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { useFormStep } from '@/hooks/use-form-step';
const schema = z.object({
routineComplexity: z.enum(['minimal', 'moderate', 'comprehensive']),
});
const routineOptions = [
{ value: 'minimal', label: 'Minimal - 2-3 products' },
{ value: 'moderate', label: 'Moderate - 5-7 products' },
{ value: 'comprehensive', label: 'Comprehensive - 8+ products' },
];
export const StepRoutineComplexity = ({ currentStep }: { currentStep: number }) => {
const { form, handleNextOverride, handleBack, getLatestState } = useFormStep(
schema,
currentStep,
13
);
const customHandleSubmit = (data: any) => {
if (data.routineComplexity === 'minimal') {
// Skip Budget Allocation step (step 10), go directly to Ethical Preferences (step 11)
handleNextOverride(11, data);
} else {
// Normal flow - go to next step
handleNextOverride(currentStep + 1, data);
}
};
const handleBackNavigation = () => {
const state = getLatestState();
// If minimal was selected and we're at ethical preferences, go back to routine complexity
if (state.currentStep === 11 && state.formData.routineComplexity === 'minimal') {
handleBackOverride(9);
} else {
handleBack();
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Routine Complexity</h2>
<p className="text-gray-600">Choose the complexity level that suits you</p>
</div>
<form onSubmit={form.handleSubmit(customHandleSubmit)} className="space-y-4">
<div className="space-y-3">
{routineOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => form.setValue('routineComplexity', option.value)}
className={`w-full p-4 border rounded-lg text-left transition ${
form.watch('routineComplexity') === option.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
}`}
>
<div className="font-semibold">{option.label}</div>
</button>
))}
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={handleBackNavigation}>
Back
</Button>
<Button type="submit">Next</Button>
</div>
</form>
</div>
);
};
This component implements conditional navigation in two ways. First, the customSubmitHandler function is triggered once the form is valid and submitted. It captures the form data upon submission and checks the selected Routine Complexity. If the user selects "Minimal", it calls handleNextOverride with step 11 instead of the normal next step. If no special conditions like skipping steps are met, it defaults to handleNextOverride(currentStep + 1) which advances to the next step in the regular sequence.
For back navigation, we need to ensure we jump to the right step and don't show users something they were never asked initially. If the user selected "Minimal" and was directed to step 11, navigating back would normally take them to step 10 (Budget Allocation). However, since that step was skipped, we want to send them back to step 9 (Routine Complexity). Since we control how users move forward, we can also control how they move backwards by checking the current state and form data.
Step 7: Creating a Step with Conditionally Displayed Fields
Let's imagine that we want to display more options dynamically based on the user's choice within the same step. Consider the Skin Goals step. If the user selects "Acne", they are prompted to provide additional details about their acne type: Hormonal, Stress-Related, or Congestion Acne. On the other hand, if they select other skin goals such as Brightening, Pore Minimization, Anti-Aging, or Hydration, no additional questions appear, and they can move to the next step immediately.
Create a new file src/components/steps/step-skin-goals.tsx:
// File: src/components/steps/step-skin-goals.tsx
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { useFormStep } from '@/hooks/use-form-step';
const schema = z.discriminatedUnion('skinGoal', [
z.object({
skinGoal: z.literal('acne'),
acneType: z.enum(['hormonal', 'stressRelated', 'congestion'], {
errorMap: () => ({ message: 'Please select an acne type' }),
}),
}),
z.object({
skinGoal: z.enum(['brightening', 'poreMini', 'antiAging', 'hydration']),
}),
]);
const skinGoalOptions = [
{ value: 'acne', label: 'Acne Control' },
{ value: 'brightening', label: 'Brightening' },
{ value: 'poreMini', label: 'Pore Minimization' },
{ value: 'antiAging', label: 'Anti-Aging' },
{ value: 'hydration', label: 'Hydration' },
];
const acneTypeOptions = [
{ value: 'hormonal', label: 'Hormonal' },
{ value: 'stressRelated', label: 'Stress-Related' },
{ value: 'congestion', label: 'Congestion' },
];
export const StepSkinGoals = ({ currentStep }: { currentStep: number }) => {
const { form, handleNext, handleBack } = useFormStep(schema, currentStep, 13);
const selectedGoal = form.watch('skinGoal');
const isAcneSelected = selectedGoal === 'acne';
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Skin Goals</h2>
<p className="text-gray-600">What are your primary skincare goals?</p>
</div>
<form onSubmit={form.handleSubmit((data) => handleNext(data))} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{skinGoalOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => form.setValue('skinGoal', option.value)}
className={`p-4 border rounded-lg transition ${
selectedGoal === option.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
}`}
>
{option.label}
</button>
))}
</div>
{isAcneSelected && (
<div className="space-y-3 mt-6 p-4 bg-blue-50 rounded-lg">
<p className="font-semibold">Please specify your acne type:</p>
{acneTypeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => form.setValue('acneType', option.value)}
className={`w-full p-3 border rounded text-left transition ${
form.watch('acneType') === option.value
? 'border-blue-500 bg-white'
: 'border-gray-300'
}`}
>
{option.label}
</button>
))}
</div>
)}
{form.formState.errors.skinGoal && (
<p className="text-red-500">{form.formState.errors.skinGoal.message}</p>
)}
{form.formState.errors.acneType && (
<p className="text-red-500">{form.formState.errors.acneType.message}</p>
)}
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<Button type="submit">Next</Button>
</div>
</form>
</div>
);
};
We can achieve this by conditionally rendering fields within the same step, ensuring that users only see the questions relevant to their selected goal. This approach maintains a clean and intuitive form experience, preventing unnecessary input fields from cluttering the UI.
An important caveat here is the schema form setup. Notice how it utilizes Zod's discriminatedUnion for validation. A discriminated union, used with z.discriminatedUnion(), is ideal for forms that include conditional fields based on the user's choice.
In the context of our form, it helps manage field validation only when needed. Certain details are only required depending on the user's selection — for example, if the user selects "Acne" as their skin goal, they must specify their acne type (Hormonal, Stress-Related, or Congestion). However, if they choose Brightening, Pore Minimization, Anti-Aging, or Hydration, no additional details are needed.
This also improves TypeScript inference — TypeScript can infer which fields are required based on the selected skin goal. If "Acne" is chosen, TypeScript ensures the user provides an acne type, but skips that validation for other skin goals. The discriminated union guarantees that only relevant fields are displayed and validated, preventing unnecessary validation errors.
Step 8: Coordinator Component That Ties Everything Together
The final missing piece is the Coordinator Component, which will reference each step component and assign the correct order in the entire flow. This is where we control the sequence of steps and determine which component should be displayed at each step.
Create a new file src/app.tsx:
// File: src/app.tsx
import { useFormStore } from '@/lib/store';
import { Step1 } from '@/components/steps/step-1';
import { StepSkinGoals } from '@/components/steps/step-skin-goals';
import { StepRoutineComplexity } from '@/components/steps/step-routine-complexity';
import { StepSummary } from '@/components/steps/step-summary';
export const MultiStepForm = () => {
const { currentStep, formData } = useFormStore();
const handleStepSubmit = (data: any) => {
// Handle any step-specific logic here
};
const renderStep = () => {
switch (currentStep) {
case 0:
return <Step1 onNext={handleStepSubmit} />;
case 2:
return <StepSkinGoals currentStep={currentStep} />;
case 9:
return <StepRoutineComplexity currentStep={currentStep} />;
case 12:
return <StepSummary />;
default:
return <div>Loading...</div>;
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<div className="mb-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Skincare Routine Builder</h1>
<span className="text-gray-600">Step {currentStep + 1} of 13</span>
</div>
</div>
{renderStep()}
</div>
);
};
To determine the current step the user is on, we can access the state from our store. Since this state is saved in localStorage, it ensures that the user will be taken back to the most recent step, even if they close and reopen the browser, restart their laptop, or experience a connection reset.
The two main elements of this component are using a switch statement based on the current step to determine which component should be rendered, and dynamically rendering components. The current step is derived from Zustand's currentStep state, and the corresponding component is dynamically rendered based on the user's progress.
Passing the step into each component provides greater modularity and an easier way to swap, delete, or add steps.
Summary Step
The final step should display a summary of all collected information. This gives users a chance to review their choices before submission:
// File: src/components/steps/step-summary.tsx
import { useFormStore } from '@/lib/store';
import { Button } from '@/components/ui/button';
export const StepSummary = () => {
const { formData, resetForm } = useFormStore();
const handleSubmit = async () => {
// Submit form data to your backend
console.log('Submitting form data:', formData);
// After successful submission, optionally reset the form
// resetForm();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Review Your Answers</h2>
<p className="text-gray-600">Here's a summary of your skincare profile</p>
</div>
<div className="bg-gray-50 p-6 rounded-lg space-y-4">
<div>
<p className="text-sm text-gray-600">Skin Type</p>
<p className="font-semibold">{formData.skinType}</p>
</div>
<div>
<p className="text-sm text-gray-600">Skin Goals</p>
<p className="font-semibold">{formData.skinGoals?.join(', ')}</p>
</div>
<div>
<p className="text-sm text-gray-600">Routine Complexity</p>
<p className="font-semibold">{formData.routineComplexity}</p>
</div>
</div>
<div className="flex justify-center">
<Button onClick={handleSubmit} size="lg">
Complete Skincare Profile
</Button>
</div>
</div>
);
};
Using Shadcn UI Components in Multi-Step Forms
Throughout this tutorial, we've used Shadcn UI's Button component, but Shadcn offers a rich component library perfect for form interfaces. Here's how to leverage more Shadcn components for your multi-step forms:
Form Components That Work Great
Select Dropdowns
Use Shadcn's Select component for dropdowns like age groups or skin type selections. The component integrates seamlessly with React Hook Form's control prop.
Radio Groups For exclusive selections (like routine complexity), Shadcn's RadioGroup provides better accessibility than custom button styling.
Checkboxes For multi-select fields (skin goals, environmental factors), use Shadcn's Checkbox with proper form field handling.
Input Components Text inputs, textareas, and specialized inputs all work natively with React Hook Form's register function.
Icon Integration
For icon integration, combine Shadcn with Lucide React icons.
Styling Consistency
One benefit of Shadcn is that all components share the same design tokens. Your multi-step form will automatically match your app's theme without additional styling work.
Adapting This Pattern for Next.js
This multi-step form pattern works excellently in Next.js applications, with some considerations for App Router and Server Components.
Client Components Required
Since this form uses React Hook Form, Zustand, and interactive state, your step components must be Client Components. Add "use client" at the top of each step file.
Integration with Server Actions
For final submission, integrate Next.js server actions. My guide on building forms with useActionState covers this pattern.
The pattern would look like:
// In your final step or summary component
"use client";
import { submitSkincareForm } from '@/actions/skincare';
import { useActionState } from 'react';
export const StepSummary = () => {
const { formData } = useFormStore();
const [state, action, isPending] = useActionState(submitSkincareForm, null);
return (
<form action={action}>
{/* Display summary */}
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Complete Profile'}
</button>
</form>
);
};
State Persistence Across Navigation
Zustand's localStorage persistence works seamlessly with Next.js client-side navigation. Users can:
- Navigate between pages without losing progress
- Refresh the page and return to their current step
- Complete the form across multiple sessions
Combining with Next.js Forms
For simpler single-step patterns, see my guide on building a newsletter form with Next.js 15.
Handling Final Submission
For production forms, connect to Next.js server actions for backend submission. Check my guide on building forms with useActionState and server actions for details.
Frequently Asked Questions
How do I create a multi-step form in React?
Use React Hook Form for form management, Zustand for state persistence across steps, and Zod for validation. Create separate components for each step, store data centrally with Zustand, and implement navigation logic to move between steps while preserving user input.
What's the best library for multi-step forms in React?
React Hook Form combined with Zustand is the most flexible approach. React Hook Form handles form state and validation, while Zustand manages global state across steps. This combination provides type safety, localStorage persistence, and clean separation of concerns.
How do I validate each step separately in a multi-step form?
Create a separate Zod schema for each step and use React Hook Form's zodResolver. Validate on form submission before allowing navigation to the next step. This ensures users can't proceed with invalid data.
Can I use this pattern with Next.js?
Yes! This pattern works perfectly with Next.js. Use client components for the form steps, and integrate with server actions for final submission. The Zustand state management ensures data persists across client-side navigation.
How do I persist form data if users close the browser?
Use Zustand's persist middleware, which automatically saves form state to localStorage. When users return, their progress is restored from localStorage, allowing them to continue where they left off.
Should I use Zod or Zustand for forms?
Both! They serve different purposes. Zod validates your data schema and ensures type safety. Zustand manages state across components and steps. Use Zod for validation rules and Zustand for storing form data between steps.
How do I add Shadcn UI components to my multi-step form?
Import Shadcn components like Button, Input, and Select into your step components. The article demonstrates using Shadcn's form components with React Hook Form's register and control props for seamless integration.
What's the difference between using one form instance vs multiple form instances?
Multiple instances (one per step) provide better validation control, type safety, and error localization. Each step validates independently. Single instance forms are simpler but make conditional validation and type inference more complex.
Bonus: Add a Honeypot to Prevent Bots
A lightweight protection against basic bots is a honeypot—a hidden input field that legitimate users won't see but bots typically fill. Add it to your first step:
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
Then check during submission:
const honeypot = document.querySelector('input[name="website"]') as HTMLInputElement;
if (honeypot?.value) return; // Reject bot submission
For stronger protection, integrate Google reCAPTCHA or implement backend rate limiting.
Wrapping Up
With this guide, we've built an elegant, fully responsive skincare builder. You can adapt this approach to whatever you need — from custom car configurations and course enrollment systems to catering service ordering platforms. While currently frontend-only, it's easily extendable with backend integration. For instance, you could connect to Shopify's API to fetch real product data and present recommendations in the final step, or for educational platforms requiring legally binding agreements, pre-fill contracts with user data and ask for signatures directly within the flow.
By combining React Hook Form's validation, Zustand's state management, and Zod's schema enforcement, you can craft multi-step forms with conditional logic, data persistence, and a modular architecture.
Do you want additional features like multi-language support or connecting the form to an API? Let me know in the comments below!
Link to the demo tool.
Link to the GitHub repo.
Thanks, Matija
Comments
You might be interested in

15th April 2025

18th March 2025

13th January 2025

22nd August 2025