Building Form Wizards with React Hook Form and Zod

12 min read
React Forms Tutorial

Multi-step forms (or form wizards) are everywhere—onboarding flows, checkout processes, complex applications. But building them well is tricky. You need validation, state persistence, proper navigation, and error handling across multiple steps.

Here's how to build them right using React Hook Form and Zod.

Why Form Wizards Are Hard

I've seen developers try to manage multi-step forms with useState for each field, manually tracking validation errors, and struggling when users navigate back and forth between steps. The code becomes a mess of conditional logic and state management.

The problems you'll face:

State persistence: Users expect their data to persist when they go back to a previous step. Lose that data and they'll abandon your form.

Validation timing: Should you validate when they click "Next" or as they type? Both? What happens if they skip ahead?

Error handling: How do you show errors for a step the user isn't currently viewing? Do you prevent navigation to the next step if there are errors?

Aha moment: I once built a 5-step onboarding form with plain React state. Users kept losing their data when navigating between steps. I spent two days debugging before realizing I needed proper form state management. React Hook Form solved this in 30 minutes.

The Setup

First, install the dependencies:

npm install react-hook-form zod @hookform/resolvers

React Hook Form manages form state and validation. Zod defines type-safe schemas. @hookform/resolvers connects them.

Step 1: Define Your Schema

Start with the schema. This defines what data you're collecting and the validation rules. Zod gives you TypeScript types for free.

import { z } from 'zod';

// Step 1: Personal Info
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'),
});

// Step 2: Preferences
const preferencesSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']),
  notifications: z.boolean(),
  newsletter: z.boolean(),
});

// Step 3: Notifications (conditional on preferences)
const notificationSettingsSchema = z.object({
  email: z.boolean(),
  push: z.boolean(),
  sms: z.boolean(),
});

// Full onboarding schema
const onboardingSchema = z.object({
  personalInfo: personalInfoSchema,
  preferences: preferencesSchema,
  notificationSettings: notificationSettingsSchema,
});

// TypeScript type from schema
type OnboardingData = z.infer;

Pro tip: Breaking schemas into steps makes validation easier. You can validate each step independently without validating the entire form.

Step 2: Basic Form Structure

Let's build a simple wizard structure. We'll add complexity gradually.

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export default function OnboardingWizard() {
  const [currentStep, setCurrentStep] = useState(0);
  
  const form = useForm({
    resolver: zodResolver(onboardingSchema),
    mode: 'onChange', // Validate as user types
    defaultValues: {
      personalInfo: { firstName: '', lastName: '', email: '' },
      preferences: { theme: 'system', notifications: false, newsletter: false },
      notificationSettings: { email: false, push: false, sms: false },
    },
  });

  const steps = ['Personal Info', 'Preferences', 'Notifications'];

  return (
    
{currentStep === 0 && } {currentStep === 1 && } {currentStep === 2 && } setCurrentStep(prev => prev - 1)} onNext={() => setCurrentStep(prev => prev + 1)} />
); }

Key points: We're using a single form instance across all steps. This keeps all form state in one place. The mode: 'onChange' validates as users type, giving immediate feedback.

Step 3: Building Individual Step Components

Each step is a separate component that receives the form instance.

function PersonalInfoStep({ form }: { form: UseFormReturn }) {
  const { register, formState: { errors } } = form;

  return (
    

Personal Information

{errors.personalInfo?.firstName && ( {errors.personalInfo.firstName.message} )}
{errors.personalInfo?.lastName && ( {errors.personalInfo.lastName.message} )}
{errors.personalInfo?.email && ( {errors.personalInfo.email.message} )}
); }

Notice the nested paths: 'personalInfo.firstName'. This matches our schema structure and keeps related data grouped.

Step 4: Smart Navigation with Validation

Here's where it gets interesting. You don't want users proceeding to the next step with invalid data. But you also want to let them go back freely.

function NavigationButtons({ 
  currentStep, 
  totalSteps, 
  onBack, 
  onNext,
  form 
}: NavigationProps) {
  const [isValidating, setIsValidating] = useState(false);

  const handleNext = async () => {
    setIsValidating(true);
    
    // Validate only the current step
    const stepFields = getStepFields(currentStep);
    const isValid = await form.trigger(stepFields);
    
    setIsValidating(false);
    
    if (isValid) {
      onNext();
    }
  };

  return (
    
{currentStep > 0 && ( )} {currentStep < totalSteps - 1 ? ( ) : ( )}
); } function getStepFields(step: number): Array { switch (step) { case 0: return ['personalInfo']; case 1: return ['preferences']; case 2: return ['notificationSettings']; default: return []; } }

Aha moment: The form.trigger() method is a game-changer. It lets you validate specific fields on demand. Before I discovered this, I was validating the entire form on each step, which showed errors for steps the user hadn't seen yet.

Step 5: Progress Indicator

Users need to know where they are in the process.

function ProgressIndicator({ 
  steps, 
  currentStep 
}: { 
  steps: string[]; 
  currentStep: number;
}) {
  return (
    
{steps.map((step, index) => (
{index < currentStep ? '✓' : index + 1}
{step} {index < steps.length - 1 && (
)}
))}
); }

Step 6: Conditional Fields

Real forms often have conditional logic. If a user enables notifications, show notification settings. If they disable it, hide them.

function PreferencesStep({ form }: { form: UseFormReturn }) {
  const { register, watch } = form;
  
  // Watch the notifications field
  const notificationsEnabled = watch('preferences.notifications');

  return (
    

Your Preferences

{notificationsEnabled && (

You'll be able to customize notification settings in the next step.

)}
); }

The watch() hook subscribes to field changes. When preferences.notifications changes, the component re-renders with the new value.

Step 7: Handling Form Submission

When the user completes all steps, you need to aggregate and submit the data.

const onSubmit = async (data: OnboardingData) => {
  try {
    // Transform data if needed
    const payload = {
      user: {
        firstName: data.personalInfo.firstName,
        lastName: data.personalInfo.lastName,
        email: data.personalInfo.email,
      },
      settings: {
        theme: data.preferences.theme,
        notifications: data.preferences.notifications 
          ? data.notificationSettings 
          : { email: false, push: false, sms: false },
        newsletter: data.preferences.newsletter,
      },
    };

    const response = await fetch('/api/onboarding', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

    if (!response.ok) throw new Error('Onboarding failed');

    // Redirect or show success message
    window.location.href = '/dashboard';
  } catch (error) {
    // Show error message
    form.setError('root', {
      message: 'Something went wrong. Please try again.',
    });
  }
};

Step 8: Persisting Data

Users might close their browser mid-onboarding. Let's save their progress to localStorage.

import { useEffect } from 'react';

function OnboardingWizard() {
  const [currentStep, setCurrentStep] = useState(0);
  
  const form = useForm({
    resolver: zodResolver(onboardingSchema),
    defaultValues: getDefaultValues(),
  });

  // Save to localStorage on every change
  useEffect(() => {
    const subscription = form.watch((data) => {
      localStorage.setItem('onboarding-data', JSON.stringify(data));
      localStorage.setItem('onboarding-step', String(currentStep));
    });
    return () => subscription.unsubscribe();
  }, [form, currentStep]);

  // ... rest of component
}

function getDefaultValues(): OnboardingData {
  // Try to restore from localStorage
  const saved = localStorage.getItem('onboarding-data');
  if (saved) {
    try {
      return JSON.parse(saved);
    } catch {
      // If parsing fails, use defaults
    }
  }
  
  return {
    personalInfo: { firstName: '', lastName: '', email: '' },
    preferences: { theme: 'system', notifications: false, newsletter: false },
    notificationSettings: { email: false, push: false, sms: false },
  };
}

Important: Clear localStorage after successful submission to avoid pre-filling forms for new onboarding attempts.

Common Mistakes to Avoid

Validating the entire form on each step: This shows errors for steps the user hasn't reached yet. Use form.trigger() to validate specific fields.

Not persisting state between steps: Using separate forms for each step loses data when users navigate. Use a single form instance.

Blocking backward navigation: Users should always be able to go back and change their answers. Only validate when moving forward.

Forgetting loading states: Validation and submission take time. Show loading indicators so users know something is happening.

Aha moment: I once built a checkout wizard where users couldn't go back to fix their shipping address after seeing the payment step. The support tickets taught me to never block backward navigation.

Final Thoughts

Form wizards seem complex, but they're manageable when you break them down. Define clear schemas with Zod, use a single form instance with React Hook Form, validate only what's necessary, and persist user data.

The key is progressive enhancement. Start simple—get the basic flow working first. Then add validation, error handling, persistence, and conditional logic one piece at a time.

Your users will appreciate forms that don't lose their data, validate clearly, and guide them smoothly through the process.