Loading
Create a React form wizard with step navigation, Zod validation, conditional logic, and a review screen.
You're going to build a multi-step form wizard in React that handles real-world complexity: validated inputs with Zod schemas, conditional step branching, a progress indicator, proper error states, and a final review step before submission.
Most form tutorials show you a single <input> with an onChange handler and call it done. Real forms are harder. They have interdependent fields, async validation, steps that appear or disappear based on previous answers, and users who click the back button. This tutorial addresses all of that.
You'll build an onboarding wizard with four steps: personal info, preferences, conditional details (the step changes based on user type), and a review screen. The end result is a reusable pattern you can apply to any multi-step flow — checkout, surveys, registration, KYC.
Create the form directory structure:
Every step gets its own Zod schema. This keeps validation modular — each step validates independently.
The z.infer utility extracts TypeScript types from Zod schemas automatically. You define the shape once and get both runtime validation and compile-time types.
A custom hook manages step navigation, form data, and validation state.
Visual feedback on step completion keeps users oriented.
The aria-current="step" attribute tells screen readers which step is active. Completed steps are clickable for backward navigation; future steps are disabled.
A reusable field component that handles labels, inputs, and error messages consistently.
The aria-invalid and aria-describedby attributes connect error messages to their inputs for screen reader users. The role="alert" ensures errors are announced immediately when they appear.
The details step renders different fields based on userType. This is where conditional branching happens.
The final step shows everything the user entered and lets them go back to fix mistakes.
Wire all steps together in a single page component. The wizard dynamically selects the schema for the details step based on user type.
Production forms need to handle edge cases that tutorials often skip.
Debounce expensive validation (like checking email uniqueness) to avoid firing on every keystroke:
Persist form state to sessionStorage so users don't lose progress on accidental page refresh:
Handle rapid double-clicks on the submit button by disabling it during async submission and tracking submission state. Validate the entire form one final time before sending data to your API — never trust that step-by-step validation caught everything, because users can navigate backwards and re-edit without re-validating forward steps.
npx create-next-app@latest form-wizard --typescript --tailwind --app --src-dir
cd form-wizard
npm install zodmkdir -p src/components/wizard src/lib// src/lib/schemas.ts
import { z } from "zod";
export const personalInfoSchema = z.object({
firstName: z.string().min(1, "First name is required").max(50),
lastName: z.string().min(1, "Last name is required").max(50),
email: z.string().email("Enter a valid email address"),
userType: z.enum(["individual", "business"], {
errorMap: () => ({ message: "Select a user type" }),
}),
});
export const preferencesSchema = z.object({
notifications: z.boolean(),
frequency: z.enum(["daily", "weekly", "monthly"]),
timezone: z.string().min(1, "Select a timezone"),
});
export const individualDetailsSchema = z.object({
occupation: z.string().min(1, "Occupation is required"),
experience: z.enum(["junior", "mid", "senior"]),
});
export const businessDetailsSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
employeeCount: z.number().min(1, "Must have at least 1 employee"),
industry: z.string().min(1, "Industry is required"),
});
export type PersonalInfo = z.infer<typeof personalInfoSchema>;
export type Preferences = z.infer<typeof preferencesSchema>;
export type IndividualDetails = z.infer<typeof individualDetailsSchema>;
export type BusinessDetails = z.infer<typeof businessDetailsSchema>;
export interface WizardData {
personalInfo: PersonalInfo;
preferences: Preferences;
details: IndividualDetails | BusinessDetails;
}// src/lib/useWizard.ts
import { useState, useCallback } from "react";
import { ZodSchema, ZodError } from "zod";
interface WizardStep {
id: string;
label: string;
schema: ZodSchema;
}
interface UseWizardReturn<T extends Record<string, unknown>> {
currentStep: number;
steps: WizardStep[];
data: T;
errors: Record<string, string>;
isFirstStep: boolean;
isLastStep: boolean;
next: () => boolean;
back: () => void;
updateData: (stepKey: string, values: Record<string, unknown>) => void;
goToStep: (index: number) => void;
}
export function useWizard<T extends Record<string, unknown>>(
steps: WizardStep[],
initialData: T
): UseWizardReturn<T> {
const [currentStep, setCurrentStep] = useState(0);
const [data, setData] = useState<T>(initialData);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = useCallback((): boolean => {
const step = steps[currentStep];
const stepData = data[step.id];
try {
step.schema.parse(stepData);
setErrors({});
return true;
} catch (err) {
if (err instanceof ZodError) {
const fieldErrors: Record<string, string> = {};
err.errors.forEach((e) => {
const path = e.path.join(".");
fieldErrors[path] = e.message;
});
setErrors(fieldErrors);
}
return false;
}
}, [currentStep, data, steps]);
const next = useCallback((): boolean => {
if (!validate()) return false;
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
return true;
}, [validate, steps.length]);
const back = useCallback((): void => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
setErrors({});
}, []);
const updateData = useCallback((stepKey: string, values: Record<string, unknown>): void => {
setData((prev) => ({ ...prev, [stepKey]: values }));
}, []);
const goToStep = useCallback(
(index: number): void => {
if (index >= 0 && index < steps.length) {
setCurrentStep(index);
}
},
[steps.length]
);
return {
currentStep,
steps,
data,
errors,
isFirstStep: currentStep === 0,
isLastStep: currentStep === steps.length - 1,
next,
back,
updateData,
goToStep,
};
}// src/components/wizard/ProgressBar.tsx
interface ProgressBarProps {
steps: { id: string; label: string }[];
currentStep: number;
onStepClick: (index: number) => void;
}
export function ProgressBar({ steps, currentStep, onStepClick }: ProgressBarProps) {
return (
<nav aria-label="Form progress" className="mb-8">
<ol className="flex items-center gap-2">
{steps.map((step, index) => {
const isComplete = index < currentStep;
const isCurrent = index === currentStep;
return (
<li key={step.id} className="flex items-center gap-2">
<button
onClick={() => isComplete && onStepClick(index)}
disabled={!isComplete}
aria-current={isCurrent ? "step" : undefined}
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors ${
isComplete
? "cursor-pointer bg-indigo-500 text-white hover:bg-indigo-400"
: isCurrent
? "border-2 border-indigo-500 text-indigo-400"
: "border-2 border-zinc-700 text-zinc-500"
}`}
>
{isComplete ? "✓" : index + 1}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 ${isComplete ? "bg-indigo-500" : "bg-zinc-700"}`} />
)}
</li>
);
})}
</ol>
</nav>
);
}// src/components/wizard/FormField.tsx
import { InputHTMLAttributes } from "react";
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
name: string;
}
export function FormField({ label, error, name, ...props }: FormFieldProps) {
return (
<div className="mb-4">
<label htmlFor={name} className="mb-1 block text-sm font-medium text-zinc-300">
{label}
</label>
<input
id={name}
name={name}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
className={`w-full rounded-lg border bg-zinc-900 px-3 py-2 text-white transition-colors outline-none focus:ring-2 focus:ring-indigo-500 ${
error ? "border-red-500" : "border-zinc-700"
}`}
{...props}
/>
{error && (
<p id={`${name}-error`} role="alert" className="mt-1 text-sm text-red-400">
{error}
</p>
)}
</div>
);
}// src/components/wizard/PersonalInfoStep.tsx
import { FormField } from "./FormField";
import { PersonalInfo } from "@/lib/schemas";
interface PersonalInfoStepProps {
data: PersonalInfo;
errors: Record<string, string>;
onChange: (values: Record<string, unknown>) => void;
}
export function PersonalInfoStep({ data, errors, onChange }: PersonalInfoStepProps) {
const update = (field: keyof PersonalInfo, value: string): void => {
onChange({ ...data, [field]: value });
};
return (
<div>
<h2 className="mb-6 text-xl font-semibold">Personal Information</h2>
<FormField
label="First Name"
name="firstName"
value={data.firstName}
error={errors.firstName}
onChange={(e) => update("firstName", e.target.value)}
/>
<FormField
label="Last Name"
name="lastName"
value={data.lastName}
error={errors.lastName}
onChange={(e) => update("lastName", e.target.value)}
/>
<FormField
label="Email"
name="email"
type="email"
value={data.email}
error={errors.email}
onChange={(e) => update("email", e.target.value)}
/>
<fieldset className="mt-4">
<legend className="mb-2 text-sm font-medium text-zinc-300">User Type</legend>
<div className="flex gap-4">
{(["individual", "business"] as const).map((type) => (
<label key={type} className="flex items-center gap-2 text-zinc-300">
<input
type="radio"
name="userType"
value={type}
checked={data.userType === type}
onChange={() => update("userType", type)}
className="accent-indigo-500"
/>
{type.charAt(0).toUpperCase() + type.slice(1)}
</label>
))}
</div>
{errors.userType && (
<p role="alert" className="mt-1 text-sm text-red-400">
{errors.userType}
</p>
)}
</fieldset>
</div>
);
}// src/components/wizard/DetailsStep.tsx
import { FormField } from "./FormField";
import { IndividualDetails, BusinessDetails } from "@/lib/schemas";
interface DetailsStepProps {
userType: "individual" | "business";
data: IndividualDetails | BusinessDetails;
errors: Record<string, string>;
onChange: (values: Record<string, unknown>) => void;
}
export function DetailsStep({ userType, data, errors, onChange }: DetailsStepProps) {
if (userType === "business") {
const biz = data as BusinessDetails;
return (
<div>
<h2 className="mb-6 text-xl font-semibold">Business Details</h2>
<FormField
label="Company Name"
name="companyName"
value={biz.companyName}
error={errors.companyName}
onChange={(e) => onChange({ ...biz, companyName: e.target.value })}
/>
<FormField
label="Number of Employees"
name="employeeCount"
type="number"
value={String(biz.employeeCount || "")}
error={errors.employeeCount}
onChange={(e) => onChange({ ...biz, employeeCount: Number(e.target.value) })}
/>
<FormField
label="Industry"
name="industry"
value={biz.industry}
error={errors.industry}
onChange={(e) => onChange({ ...biz, industry: e.target.value })}
/>
</div>
);
}
const ind = data as IndividualDetails;
return (
<div>
<h2 className="mb-6 text-xl font-semibold">Your Details</h2>
<FormField
label="Occupation"
name="occupation"
value={ind.occupation}
error={errors.occupation}
onChange={(e) => onChange({ ...ind, occupation: e.target.value })}
/>
<fieldset className="mt-4">
<legend className="mb-2 text-sm font-medium text-zinc-300">Experience Level</legend>
<div className="flex gap-4">
{(["junior", "mid", "senior"] as const).map((level) => (
<label key={level} className="flex items-center gap-2 text-zinc-300">
<input
type="radio"
name="experience"
value={level}
checked={ind.experience === level}
onChange={() => onChange({ ...ind, experience: level })}
className="accent-indigo-500"
/>
{level.charAt(0).toUpperCase() + level.slice(1)}
</label>
))}
</div>
</fieldset>
</div>
);
}// src/components/wizard/ReviewStep.tsx
import { WizardData } from "@/lib/schemas";
interface ReviewStepProps {
data: WizardData;
onEdit: (step: number) => void;
}
export function ReviewStep({ data, onEdit }: ReviewStepProps) {
return (
<div>
<h2 className="mb-6 text-xl font-semibold">Review Your Information</h2>
<div className="space-y-6">
<section className="rounded-lg border border-zinc-700 p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">Personal Info</h3>
<button onClick={() => onEdit(0)} className="text-sm text-indigo-400 hover:underline">
Edit
</button>
</div>
<dl className="mt-2 grid grid-cols-2 gap-2 text-sm text-zinc-400">
<dt>Name</dt>
<dd className="text-white">
{data.personalInfo.firstName} {data.personalInfo.lastName}
</dd>
<dt>Email</dt>
<dd className="text-white">{data.personalInfo.email}</dd>
<dt>Type</dt>
<dd className="text-white capitalize">{data.personalInfo.userType}</dd>
</dl>
</section>
<section className="rounded-lg border border-zinc-700 p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">Preferences</h3>
<button onClick={() => onEdit(1)} className="text-sm text-indigo-400 hover:underline">
Edit
</button>
</div>
<dl className="mt-2 grid grid-cols-2 gap-2 text-sm text-zinc-400">
<dt>Notifications</dt>
<dd className="text-white">
{data.preferences.notifications ? "Enabled" : "Disabled"}
</dd>
<dt>Frequency</dt>
<dd className="text-white capitalize">{data.preferences.frequency}</dd>
</dl>
</section>
</div>
</div>
);
}// src/app/page.tsx
"use client";
import { useMemo } from "react";
import { useWizard } from "@/lib/useWizard";
import { ProgressBar } from "@/components/wizard/ProgressBar";
import { PersonalInfoStep } from "@/components/wizard/PersonalInfoStep";
import { DetailsStep } from "@/components/wizard/DetailsStep";
import { ReviewStep } from "@/components/wizard/ReviewStep";
import {
personalInfoSchema,
preferencesSchema,
individualDetailsSchema,
businessDetailsSchema,
} from "@/lib/schemas";
const initialData = {
personalInfo: {
firstName: "",
lastName: "",
email: "",
userType: "" as "individual" | "business",
},
preferences: { notifications: true, frequency: "weekly" as const, timezone: "" },
details: { occupation: "", experience: "" as "junior" | "mid" | "senior" },
};
export default function WizardPage() {
const userType = (initialData.personalInfo.userType || "individual") as "individual" | "business";
const detailsSchema = userType === "business" ? businessDetailsSchema : individualDetailsSchema;
const steps = useMemo(
() => [
{ id: "personalInfo", label: "Personal Info", schema: personalInfoSchema },
{ id: "preferences", label: "Preferences", schema: preferencesSchema },
{ id: "details", label: "Details", schema: detailsSchema },
{ id: "review", label: "Review", schema: personalInfoSchema },
],
[detailsSchema]
);
const wizard = useWizard(steps, initialData);
const handleSubmit = (): void => {
console.log("Submitted:", wizard.data);
};
const stepComponents = [
<PersonalInfoStep
key="personal"
data={wizard.data.personalInfo}
errors={wizard.errors}
onChange={(v) => wizard.updateData("personalInfo", v)}
/>,
null, // Preferences step would go here
<DetailsStep
key="details"
userType={wizard.data.personalInfo.userType || "individual"}
data={wizard.data.details}
errors={wizard.errors}
onChange={(v) => wizard.updateData("details", v)}
/>,
<ReviewStep key="review" data={wizard.data as any} onEdit={wizard.goToStep} />,
];
return (
<main className="mx-auto max-w-xl px-4 py-12">
<ProgressBar steps={steps} currentStep={wizard.currentStep} onStepClick={wizard.goToStep} />
{stepComponents[wizard.currentStep]}
<div className="mt-8 flex justify-between">
{!wizard.isFirstStep && (
<button
onClick={wizard.back}
className="rounded-lg border border-zinc-700 px-4 py-2 text-zinc-300 hover:bg-zinc-800"
>
Back
</button>
)}
<button
onClick={wizard.isLastStep ? handleSubmit : () => wizard.next()}
className="ml-auto rounded-lg bg-indigo-500 px-6 py-2 font-medium text-white hover:bg-indigo-400"
>
{wizard.isLastStep ? "Submit" : "Continue"}
</button>
</div>
</main>
);
}// src/lib/debounce.ts
export function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}const STORAGE_KEY = "wizard-draft";
function saveProgress(data: Record<string, unknown>): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Storage full or unavailable — fail silently
}
}
function loadProgress(): Record<string, unknown> | null {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : null;
} catch {
return null;
}
}