Skip to main content
Back to Blog
Tutorials
5 min read
November 24, 2024

How to Build a Multi-Step Form Wizard in React

Create a multi-step form wizard with progress indicator, validation per step, and data persistence using React and Zod.

Ryel Banfield

Founder & Lead Developer

Multi-step forms break long forms into manageable chunks. They reduce abandonment and improve completion rates. Here is how to build one with React, React Hook Form, and Zod.

Step 1: Install Dependencies

pnpm add react-hook-form @hookform/resolvers zod

Step 2: Define Schemas Per Step

// lib/schemas.ts
import { z } from "zod";

export const personalInfoSchema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().email("Invalid email address"),
  phone: z.string().min(10, "Phone number must be at least 10 digits"),
});

export const businessInfoSchema = z.object({
  companyName: z.string().min(1, "Company name is required"),
  industry: z.string().min(1, "Select an industry"),
  website: z.string().url("Invalid URL").or(z.literal("")),
  employees: z.enum(["1-10", "11-50", "51-200", "200+"]),
});

export const projectInfoSchema = z.object({
  projectType: z.enum(["website", "web-app", "mobile-app", "ecommerce"]),
  budget: z.enum(["<5k", "5k-15k", "15k-50k", "50k+"]),
  timeline: z.enum(["asap", "1-3months", "3-6months", "flexible"]),
  description: z.string().min(20, "Provide at least 20 characters"),
});

export type PersonalInfo = z.infer<typeof personalInfoSchema>;
export type BusinessInfo = z.infer<typeof businessInfoSchema>;
export type ProjectInfo = z.infer<typeof projectInfoSchema>;

export type FormData = PersonalInfo & BusinessInfo & ProjectInfo;

Step 3: Build the Progress Indicator

function StepIndicator({
  steps,
  currentStep,
}: {
  steps: string[];
  currentStep: number;
}) {
  return (
    <div className="flex items-center justify-between">
      {steps.map((step, index) => (
        <div key={step} className="flex items-center">
          <div className="flex flex-col items-center">
            <div
              className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium ${
                index < currentStep
                  ? "bg-green-600 text-white"
                  : index === currentStep
                    ? "bg-blue-600 text-white"
                    : "bg-gray-200 text-gray-500 dark:bg-gray-700"
              }`}
            >
              {index < currentStep ? (
                <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                </svg>
              ) : (
                index + 1
              )}
            </div>
            <span className="mt-2 text-xs text-gray-500">{step}</span>
          </div>
          {index < steps.length - 1 && (
            <div
              className={`mx-4 h-0.5 w-16 ${
                index < currentStep ? "bg-green-600" : "bg-gray-200 dark:bg-gray-700"
              }`}
            />
          )}
        </div>
      ))}
    </div>
  );
}

Step 4: Build Individual Step Components

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PersonalInfo, BusinessInfo, ProjectInfo } from "@/lib/schemas";
import {
  personalInfoSchema,
  businessInfoSchema,
  projectInfoSchema,
} from "@/lib/schemas";

function PersonalInfoStep({
  data,
  onNext,
}: {
  data: Partial<PersonalInfo>;
  onNext: (data: PersonalInfo) => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PersonalInfo>({
    resolver: zodResolver(personalInfoSchema),
    defaultValues: data,
  });

  return (
    <form onSubmit={handleSubmit(onNext)} className="space-y-4">
      <div className="grid gap-4 sm:grid-cols-2">
        <div>
          <label className="block text-sm font-medium">First Name</label>
          <input
            {...register("firstName")}
            className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
          />
          {errors.firstName && (
            <p className="mt-1 text-sm text-red-500">{errors.firstName.message}</p>
          )}
        </div>
        <div>
          <label className="block text-sm font-medium">Last Name</label>
          <input
            {...register("lastName")}
            className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
          />
          {errors.lastName && (
            <p className="mt-1 text-sm text-red-500">{errors.lastName.message}</p>
          )}
        </div>
      </div>
      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          {...register("email")}
          type="email"
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
        )}
      </div>
      <div>
        <label className="block text-sm font-medium">Phone</label>
        <input
          {...register("phone")}
          type="tel"
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        />
        {errors.phone && (
          <p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
        )}
      </div>
      <div className="flex justify-end">
        <button
          type="submit"
          className="rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
        >
          Next
        </button>
      </div>
    </form>
  );
}

function BusinessInfoStep({
  data,
  onNext,
  onBack,
}: {
  data: Partial<BusinessInfo>;
  onNext: (data: BusinessInfo) => void;
  onBack: () => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<BusinessInfo>({
    resolver: zodResolver(businessInfoSchema),
    defaultValues: data,
  });

  return (
    <form onSubmit={handleSubmit(onNext)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Company Name</label>
        <input
          {...register("companyName")}
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        />
        {errors.companyName && (
          <p className="mt-1 text-sm text-red-500">{errors.companyName.message}</p>
        )}
      </div>
      <div>
        <label className="block text-sm font-medium">Industry</label>
        <select
          {...register("industry")}
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        >
          <option value="">Select an industry</option>
          <option value="technology">Technology</option>
          <option value="healthcare">Healthcare</option>
          <option value="finance">Finance</option>
          <option value="retail">Retail</option>
          <option value="other">Other</option>
        </select>
        {errors.industry && (
          <p className="mt-1 text-sm text-red-500">{errors.industry.message}</p>
        )}
      </div>
      <div>
        <label className="block text-sm font-medium">Website (optional)</label>
        <input
          {...register("website")}
          placeholder="https://example.com"
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        />
        {errors.website && (
          <p className="mt-1 text-sm text-red-500">{errors.website.message}</p>
        )}
      </div>
      <div>
        <label className="block text-sm font-medium">Number of Employees</label>
        <select
          {...register("employees")}
          className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
        >
          <option value="1-10">1-10</option>
          <option value="11-50">11-50</option>
          <option value="51-200">51-200</option>
          <option value="200+">200+</option>
        </select>
      </div>
      <div className="flex justify-between">
        <button
          type="button"
          onClick={onBack}
          className="rounded-lg border px-6 py-2 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
        >
          Back
        </button>
        <button
          type="submit"
          className="rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
        >
          Next
        </button>
      </div>
    </form>
  );
}

Step 5: Build the Wizard Container

"use client";

import { useState } from "react";
import type { FormData, PersonalInfo, BusinessInfo, ProjectInfo } from "@/lib/schemas";

const steps = ["Personal Info", "Business Info", "Project Details", "Review"];

export function FormWizard() {
  const [currentStep, setCurrentStep] = useState(0);
  const [formData, setFormData] = useState<Partial<FormData>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  function handlePersonalInfo(data: PersonalInfo) {
    setFormData((prev) => ({ ...prev, ...data }));
    setCurrentStep(1);
  }

  function handleBusinessInfo(data: BusinessInfo) {
    setFormData((prev) => ({ ...prev, ...data }));
    setCurrentStep(2);
  }

  function handleProjectInfo(data: ProjectInfo) {
    setFormData((prev) => ({ ...prev, ...data }));
    setCurrentStep(3);
  }

  async function handleSubmit() {
    setIsSubmitting(true);
    try {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(formData),
      });
      if (!response.ok) throw new Error("Submission failed");
      setCurrentStep(4); // Success state
    } catch {
      alert("Failed to submit. Please try again.");
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    <div className="mx-auto max-w-2xl">
      <StepIndicator steps={steps} currentStep={currentStep} />
      <div className="mt-8">
        {currentStep === 0 && (
          <PersonalInfoStep data={formData} onNext={handlePersonalInfo} />
        )}
        {currentStep === 1 && (
          <BusinessInfoStep
            data={formData}
            onNext={handleBusinessInfo}
            onBack={() => setCurrentStep(0)}
          />
        )}
        {currentStep === 2 && (
          <ProjectInfoStep
            data={formData}
            onNext={handleProjectInfo}
            onBack={() => setCurrentStep(1)}
          />
        )}
        {currentStep === 3 && (
          <ReviewStep
            data={formData as FormData}
            onSubmit={handleSubmit}
            onBack={() => setCurrentStep(2)}
            isSubmitting={isSubmitting}
          />
        )}
        {currentStep === 4 && <SuccessMessage />}
      </div>
    </div>
  );
}

function SuccessMessage() {
  return (
    <div className="text-center">
      <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
        <svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
        </svg>
      </div>
      <h3 className="mt-4 text-xl font-semibold">Thank you</h3>
      <p className="mt-2 text-gray-500">
        We received your information and will be in touch within 24 hours.
      </p>
    </div>
  );
}

UX Best Practices

  • Show a progress indicator so users know how many steps remain
  • Validate each step before advancing
  • Allow users to navigate backwards without losing data
  • Persist partial data to localStorage for recovery
  • Keep each step focused on one topic
  • Show a summary/review step before final submission

Need Custom Forms for Your Business?

We build conversion-optimized forms and lead capture systems for businesses. Contact us to discuss your requirements.

Reactformsmulti-stepwizardtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles