Development

Building Multi-Step Form Wizards with React Hook Form and Zod

FrootsyTech Solutions
15 min read

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:

  1. Unified state - All form data lives in one place
  2. Proper validation - Zod sees all fields for complex cross-field rules
  3. Easier submission - One handleSubmit for the entire form
  4. 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:

  1. Use a single form instance with React Hook Form for all steps
  2. Validate per-step with form.trigger() for specific fields
  3. Manage step state separately from form state
  4. Persist data to localStorage or a state management library
  5. Handle file uploads with FormData and proper previews
  6. 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

Free Checklist

Complete SaaS Development Checklist

Download our comprehensive 50-point checklist to build your SaaS product right the first time.

Download Free Checklist

Share this article

FrootsyTech Solutions

FrootsyTech 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.