Skip to main content
Back to Blog
Tutorials
4 min read
January 19, 2025

How to Build a Stepper Wizard With Validation in React

Create a multi-step wizard with per-step validation, progress tracking, step navigation guards, and form state persistence.

Ryel Banfield

Founder & Lead Developer

Multi-step wizards break complex forms into manageable steps. Here is how to build one with proper validation.

Step Configuration

// types.ts
import { z } from "zod";

export interface StepConfig {
  id: string;
  title: string;
  description?: string;
  schema: z.ZodSchema;
}

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"),
});

export const addressSchema = z.object({
  street: z.string().min(1, "Street is required"),
  city: z.string().min(1, "City is required"),
  state: z.string().min(1, "State is required"),
  zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"),
});

export const preferencesSchema = z.object({
  plan: z.enum(["basic", "pro", "enterprise"]),
  newsletter: z.boolean(),
  terms: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms" }),
  }),
});

export const steps: StepConfig[] = [
  {
    id: "personal",
    title: "Personal Info",
    description: "Basic information about you",
    schema: personalInfoSchema,
  },
  {
    id: "address",
    title: "Address",
    description: "Your mailing address",
    schema: addressSchema,
  },
  {
    id: "preferences",
    title: "Preferences",
    description: "Choose your plan",
    schema: preferencesSchema,
  },
];

Stepper Hook

"use client";

import { useState, useCallback } from "react";
import type { StepConfig } from "./types";
import type { z } from "zod";

interface UseStepperOptions {
  steps: StepConfig[];
  onComplete: (data: Record<string, unknown>) => void;
}

export function useStepper({ steps, onComplete }: UseStepperOptions) {
  const [currentStep, setCurrentStep] = useState(0);
  const [formData, setFormData] = useState<Record<string, unknown>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());

  const currentConfig = steps[currentStep];
  const isFirst = currentStep === 0;
  const isLast = currentStep === steps.length - 1;

  const validate = useCallback(
    (data: Record<string, unknown>): boolean => {
      const result = currentConfig.schema.safeParse(data);
      if (result.success) {
        setErrors({});
        return true;
      }
      const fieldErrors: Record<string, string> = {};
      for (const issue of result.error.issues) {
        const key = issue.path.join(".");
        if (!fieldErrors[key]) {
          fieldErrors[key] = issue.message;
        }
      }
      setErrors(fieldErrors);
      return false;
    },
    [currentConfig],
  );

  const next = useCallback(
    (stepData: Record<string, unknown>) => {
      if (!validate(stepData)) return false;

      const merged = { ...formData, ...stepData };
      setFormData(merged);
      setCompletedSteps((prev) => new Set([...prev, currentStep]));

      if (isLast) {
        onComplete(merged);
        return true;
      }

      setCurrentStep((s) => s + 1);
      setErrors({});
      return true;
    },
    [currentStep, formData, isLast, onComplete, validate],
  );

  const back = useCallback(() => {
    if (isFirst) return;
    setCurrentStep((s) => s - 1);
    setErrors({});
  }, [isFirst]);

  const goTo = useCallback(
    (step: number) => {
      // Only allow going to completed steps or the next incomplete step
      if (step <= currentStep || completedSteps.has(step - 1)) {
        setCurrentStep(step);
        setErrors({});
      }
    },
    [currentStep, completedSteps],
  );

  return {
    currentStep,
    currentConfig,
    formData,
    errors,
    completedSteps,
    isFirst,
    isLast,
    next,
    back,
    goTo,
    totalSteps: steps.length,
  };
}

Progress Indicator

interface ProgressProps {
  steps: StepConfig[];
  currentStep: number;
  completedSteps: Set<number>;
  onStepClick: (step: number) => void;
}

export function StepProgress({
  steps,
  currentStep,
  completedSteps,
  onStepClick,
}: ProgressProps) {
  return (
    <nav aria-label="Progress">
      <ol className="flex items-center">
        {steps.map((step, index) => {
          const isCompleted = completedSteps.has(index);
          const isCurrent = currentStep === index;
          const isClickable = isCompleted || index <= currentStep;

          return (
            <li
              key={step.id}
              className={`flex items-center ${index < steps.length - 1 ? "flex-1" : ""}`}
            >
              <button
                onClick={() => isClickable && onStepClick(index)}
                disabled={!isClickable}
                className={`flex items-center gap-2 ${isClickable ? "cursor-pointer" : "cursor-not-allowed"}`}
                aria-current={isCurrent ? "step" : undefined}
              >
                <span
                  className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
                    isCompleted
                      ? "bg-green-500 text-white"
                      : isCurrent
                        ? "bg-primary text-primary-foreground"
                        : "bg-muted text-muted-foreground"
                  }`}
                >
                  {isCompleted ? (
                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
                      <path
                        d="M2.5 7L5.5 10L11.5 4"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  ) : (
                    index + 1
                  )}
                </span>
                <span
                  className={`text-sm hidden sm:block ${
                    isCurrent ? "font-medium" : "text-muted-foreground"
                  }`}
                >
                  {step.title}
                </span>
              </button>
              {index < steps.length - 1 && (
                <div
                  className={`flex-1 h-0.5 mx-4 ${
                    completedSteps.has(index) ? "bg-green-500" : "bg-muted"
                  }`}
                />
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

Step Forms

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { StepConfig } from "./types";

interface StepFormProps {
  config: StepConfig;
  defaultValues: Record<string, unknown>;
  errors: Record<string, string>;
  onSubmit: (data: Record<string, unknown>) => boolean;
  onBack?: () => void;
  isFirst: boolean;
  isLast: boolean;
}

export function StepForm({
  config,
  defaultValues,
  onSubmit,
  onBack,
  isFirst,
  isLast,
}: StepFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(config.schema),
    defaultValues,
  });

  return (
    <form onSubmit={handleSubmit((data) => onSubmit(data))} className="space-y-4">
      <div>
        <h2 className="text-xl font-semibold">{config.title}</h2>
        {config.description && (
          <p className="text-sm text-muted-foreground mt-1">{config.description}</p>
        )}
      </div>

      {/* Render fields based on step ID */}
      {config.id === "personal" && (
        <>
          <Field label="First Name" error={errors.firstName?.message as string}>
            <input {...register("firstName")} className="w-full border rounded-md px-3 py-2" />
          </Field>
          <Field label="Last Name" error={errors.lastName?.message as string}>
            <input {...register("lastName")} className="w-full border rounded-md px-3 py-2" />
          </Field>
          <Field label="Email" error={errors.email?.message as string}>
            <input type="email" {...register("email")} className="w-full border rounded-md px-3 py-2" />
          </Field>
        </>
      )}

      {config.id === "address" && (
        <>
          <Field label="Street" error={errors.street?.message as string}>
            <input {...register("street")} className="w-full border rounded-md px-3 py-2" />
          </Field>
          <Field label="City" error={errors.city?.message as string}>
            <input {...register("city")} className="w-full border rounded-md px-3 py-2" />
          </Field>
          <div className="grid grid-cols-2 gap-4">
            <Field label="State" error={errors.state?.message as string}>
              <input {...register("state")} className="w-full border rounded-md px-3 py-2" />
            </Field>
            <Field label="ZIP Code" error={errors.zip?.message as string}>
              <input {...register("zip")} className="w-full border rounded-md px-3 py-2" />
            </Field>
          </div>
        </>
      )}

      {config.id === "preferences" && (
        <>
          <Field label="Plan" error={errors.plan?.message as string}>
            <select {...register("plan")} className="w-full border rounded-md px-3 py-2">
              <option value="">Select a plan</option>
              <option value="basic">Basic</option>
              <option value="pro">Pro</option>
              <option value="enterprise">Enterprise</option>
            </select>
          </Field>
          <label className="flex items-center gap-2">
            <input type="checkbox" {...register("newsletter")} />
            <span className="text-sm">Subscribe to newsletter</span>
          </label>
          <Field label="" error={errors.terms?.message as string}>
            <label className="flex items-center gap-2">
              <input type="checkbox" {...register("terms")} />
              <span className="text-sm">I accept the terms and conditions</span>
            </label>
          </Field>
        </>
      )}

      <div className="flex justify-between pt-4">
        {!isFirst ? (
          <button
            type="button"
            onClick={onBack}
            className="px-4 py-2 border rounded-md text-sm"
          >
            Back
          </button>
        ) : (
          <div />
        )}
        <button
          type="submit"
          className="px-6 py-2 bg-primary text-primary-foreground rounded-md text-sm"
        >
          {isLast ? "Submit" : "Continue"}
        </button>
      </div>
    </form>
  );
}

function Field({
  label,
  error,
  children,
}: {
  label: string;
  error?: string;
  children: React.ReactNode;
}) {
  return (
    <div>
      {label && <label className="block text-sm font-medium mb-1">{label}</label>}
      {children}
      {error && <p className="text-sm text-red-500 mt-1">{error}</p>}
    </div>
  );
}

Need Complex Form Flows?

We build multi-step form experiences that convert. Contact us to improve your forms.

stepperwizardformsvalidationReacttutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles