React Multi-Step Form Stepper: Per-Step Validation, Progress Persistence, and Back/Forward Navigation
Build a production multi-step form in React. Covers step state management with useReducer, per-step Zod schema validation, localStorage progress persistence so users can resume, back/forward navigation with step guard, and animated step transitions.
Multi-step forms break complex data collection into digestible steps โ users aren't overwhelmed by seeing 20 fields at once, and each step can validate independently before proceeding. The tricky parts are per-step validation (validate only the current step's fields on "Next"), progress persistence (user shouldn't lose data if they navigate away), and back navigation (don't re-validate on going back).
Step Configuration and Types
// lib/form-stepper/types.ts
import { z } from "zod";
// Step schemas โ validate per step, not the whole form
export const Step1Schema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Valid email required"),
});
export const Step2Schema = z.object({
company: z.string().min(1, "Company name is required"),
role: z.string().min(1, "Role is required"),
teamSize: z.enum(["1", "2-5", "6-20", "21-100", "100+"], {
errorMap: () => ({ message: "Please select team size" }),
}),
});
export const Step3Schema = z.object({
useCase: z.string().min(10, "Please describe your use case (min 10 characters)"),
hearAbout: z.string().optional(),
agreedToTerms: z.boolean().refine((v) => v, "You must agree to the terms"),
});
// Full form schema (union of all steps)
export const FullFormSchema = Step1Schema.merge(Step2Schema).merge(Step3Schema);
export type FullFormData = z.infer<typeof FullFormSchema>;
export const STEP_SCHEMAS = [Step1Schema, Step2Schema, Step3Schema] as const;
export interface StepConfig {
id: number;
title: string;
subtitle: string;
}
export const STEPS: StepConfig[] = [
{ id: 0, title: "Personal Info", subtitle: "Tell us about yourself" },
{ id: 1, title: "Company Details", subtitle: "About your organization" },
{ id: 2, title: "Use Case", subtitle: "How can we help?" },
];
Form State with useReducer
// lib/form-stepper/reducer.ts
import type { FullFormData } from "./types";
export interface FormState {
currentStep: number;
completedSteps: Set<number>;
data: Partial<FullFormData>;
isSubmitting: boolean;
isSubmitted: boolean;
}
export type FormAction =
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "GO_TO_STEP"; step: number }
| { type: "UPDATE_DATA"; data: Partial<FullFormData> }
| { type: "MARK_STEP_COMPLETE"; step: number }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "SUBMIT_SUCCESS" }
| { type: "RESTORE"; state: Partial<FormState> };
export const initialState: FormState = {
currentStep: 0,
completedSteps: new Set(),
data: {},
isSubmitting: false,
isSubmitted: false,
};
export function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "NEXT_STEP":
return {
...state,
completedSteps: new Set([...state.completedSteps, state.currentStep]),
currentStep: Math.min(state.currentStep + 1, STEPS.length - 1),
};
case "PREV_STEP":
return { ...state, currentStep: Math.max(state.currentStep - 1, 0) };
case "GO_TO_STEP":
// Only allow going to completed steps or current step
if (action.step > state.currentStep && !state.completedSteps.has(action.step - 1)) {
return state; // Block skipping ahead
}
return { ...state, currentStep: action.step };
case "UPDATE_DATA":
return { ...state, data: { ...state.data, ...action.data } };
case "MARK_STEP_COMPLETE":
return {
...state,
completedSteps: new Set([...state.completedSteps, action.step]),
};
case "SET_SUBMITTING":
return { ...state, isSubmitting: action.value };
case "SUBMIT_SUCCESS":
return { ...state, isSubmitting: false, isSubmitted: true };
case "RESTORE":
return {
...state,
...action.state,
completedSteps: new Set(action.state.completedSteps ?? []),
};
default:
return state;
}
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Progress Persistence with localStorage
// hooks/use-form-persistence.ts
"use client";
import { useEffect, useCallback } from "react";
import type { FormState } from "@/lib/form-stepper/reducer";
const STORAGE_KEY = "onboarding-form-v1";
export function useFormPersistence(
state: FormState,
dispatch: React.Dispatch<any>
) {
// Restore saved state on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
const parsed = JSON.parse(saved) as {
data: Partial<FormState["data"]>;
currentStep: number;
completedSteps: number[];
};
// Only restore if not already submitted
dispatch({
type: "RESTORE",
state: {
data: parsed.data,
currentStep: parsed.currentStep,
completedSteps: parsed.completedSteps,
},
});
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}, []); // Only on mount
// Save state on every change
useEffect(() => {
if (state.isSubmitted) {
localStorage.removeItem(STORAGE_KEY); // Clear on success
return;
}
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
data: state.data,
currentStep: state.currentStep,
completedSteps: Array.from(state.completedSteps),
})
);
}, [state.data, state.currentStep, state.completedSteps, state.isSubmitted]);
const clearPersisted = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
}, []);
return { clearPersisted };
}
Main FormStepper Component
// components/form-stepper/form-stepper.tsx
"use client";
import { useReducer, useCallback } from "react";
import { formReducer, initialState } from "@/lib/form-stepper/reducer";
import { STEPS, STEP_SCHEMAS, type FullFormData } from "@/lib/form-stepper/types";
import { useFormPersistence } from "@/hooks/use-form-persistence";
import { StepIndicator } from "./step-indicator";
import { Step1Form } from "./steps/step1";
import { Step2Form } from "./steps/step2";
import { Step3Form } from "./steps/step3";
const STEP_COMPONENTS = [Step1Form, Step2Form, Step3Form];
interface FormStepperProps {
onSubmit: (data: FullFormData) => Promise<void>;
}
export function FormStepper({ onSubmit }: FormStepperProps) {
const [state, dispatch] = useReducer(formReducer, initialState);
const { clearPersisted } = useFormPersistence(state, dispatch);
const handleStepComplete = useCallback(
async (stepData: Partial<FullFormData>) => {
// Validate current step's data against its schema
const schema = STEP_SCHEMAS[state.currentStep];
const parsed = schema.safeParse({ ...state.data, ...stepData });
if (!parsed.success) {
// Validation errors are handled by the step component
return false;
}
dispatch({ type: "UPDATE_DATA", data: stepData });
if (state.currentStep === STEPS.length - 1) {
// Final step: submit
dispatch({ type: "SET_SUBMITTING", value: true });
try {
const fullData = { ...state.data, ...stepData } as FullFormData;
await onSubmit(fullData);
dispatch({ type: "SUBMIT_SUCCESS" });
clearPersisted();
} catch (err) {
dispatch({ type: "SET_SUBMITTING", value: false });
throw err;
}
} else {
dispatch({ type: "NEXT_STEP" });
}
return true;
},
[state, dispatch, onSubmit, clearPersisted]
);
if (state.isSubmitted) {
return (
<div className="text-center py-16">
<div className="text-5xl mb-4">โ
</div>
<h2 className="text-2xl font-bold text-gray-900">You're all set!</h2>
<p className="text-gray-500 mt-2">We'll be in touch within 24 hours.</p>
</div>
);
}
const CurrentStep = STEP_COMPONENTS[state.currentStep];
return (
<div className="max-w-xl mx-auto">
{/* Step indicator */}
<StepIndicator
steps={STEPS}
currentStep={state.currentStep}
completedSteps={state.completedSteps}
onStepClick={(step) => dispatch({ type: "GO_TO_STEP", step })}
/>
{/* Step form */}
<div className="mt-8 bg-white border border-gray-200 rounded-2xl p-8">
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900">
{STEPS[state.currentStep].title}
</h2>
<p className="text-gray-500 text-sm mt-1">
{STEPS[state.currentStep].subtitle}
</p>
</div>
<CurrentStep
defaultValues={state.data}
onComplete={handleStepComplete}
onBack={
state.currentStep > 0
? () => dispatch({ type: "PREV_STEP" })
: undefined
}
isSubmitting={state.isSubmitting}
isLastStep={state.currentStep === STEPS.length - 1}
/>
</div>
</div>
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Step Component (with RHF + Zod)
// components/form-stepper/steps/step1.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Step1Schema } from "@/lib/form-stepper/types";
import type { z } from "zod";
import { ArrowRight, ArrowLeft } from "lucide-react";
type Step1Data = z.infer<typeof Step1Schema>;
interface StepProps {
defaultValues: Partial<Step1Data>;
onComplete: (data: Step1Data) => Promise<boolean>;
onBack?: () => void;
isSubmitting: boolean;
isLastStep: boolean;
}
export function Step1Form({ defaultValues, onComplete, onBack, isSubmitting, isLastStep }: StepProps) {
const { register, handleSubmit, formState: { errors, isSubmitting: isStepSubmitting } } =
useForm<Step1Data>({
resolver: zodResolver(Step1Schema),
defaultValues: {
firstName: defaultValues.firstName ?? "",
lastName: defaultValues.lastName ?? "",
email: defaultValues.email ?? "",
},
});
return (
<form onSubmit={handleSubmit(onComplete)} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First name *</label>
<input
{...register("firstName")}
type="text"
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.firstName && (
<p className="text-xs text-red-600 mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last name *</label>
<input
{...register("lastName")}
type="text"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.lastName && (
<p className="text-xs text-red-600 mt-1">{errors.lastName.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input
{...register("email")}
type="email"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email.message}</p>
)}
</div>
<div className="flex items-center justify-between pt-2">
{onBack ? (
<button type="button" onClick={onBack} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-4 h-4" /> Back
</button>
) : <div />}
<button
type="submit"
disabled={isStepSubmitting || isSubmitting}
className="flex items-center gap-2 px-5 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLastStep ? "Submit" : "Next"}
{!isLastStep && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</form>
);
}
Step Indicator Component
// components/form-stepper/step-indicator.tsx
"use client";
import { Check } from "lucide-react";
import type { StepConfig } from "@/lib/form-stepper/types";
interface StepIndicatorProps {
steps: StepConfig[];
currentStep: number;
completedSteps: Set<number>;
onStepClick: (step: number) => void;
}
export function StepIndicator({ steps, currentStep, completedSteps, onStepClick }: StepIndicatorProps) {
return (
<div className="flex items-center">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(i);
const isCurrent = currentStep === i;
const isClickable = isCompleted || i <= currentStep;
return (
<div key={step.id} className="flex items-center flex-1 last:flex-none">
<button
onClick={() => isClickable && onStepClick(i)}
disabled={!isClickable}
className={`
flex items-center gap-2.5
${isClickable ? "cursor-pointer" : "cursor-not-allowed opacity-40"}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0
${isCompleted ? "bg-blue-600 text-white" :
isCurrent ? "bg-blue-100 text-blue-700 ring-2 ring-blue-500" :
"bg-gray-100 text-gray-400"}
`}>
{isCompleted ? <Check className="w-4 h-4" /> : i + 1}
</div>
<div className="hidden sm:block text-left">
<p className={`text-xs font-semibold ${isCurrent ? "text-blue-700" : isCompleted ? "text-gray-700" : "text-gray-400"}`}>
{step.title}
</p>
<p className="text-xs text-gray-400">{step.subtitle}</p>
</div>
</button>
{i < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-3 ${isCompleted ? "bg-blue-300" : "bg-gray-200"}`} />
)}
</div>
);
})}
</div>
);
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| 3-step form with per-step validation | 1 dev | 2โ3 days | $600โ1,200 |
| + localStorage persistence + restore | 1 dev | 1 day | $300โ600 |
| + Animated step transitions + skip logic | 1 dev | 1 day | $300โ600 |
See Also
- React Hook Form with Zod Validation
- SaaS Onboarding Flow
- React Optimistic Updates
- Next.js Server Actions Patterns
- React Modal Patterns
Working With Viprasol
Multi-step forms fail in two common ways: validating all fields on every "Next" click (annoying), or not validating at all until final submit (data loss). Our team builds form steppers with useReducer for predictable state transitions, per-step Zod schemas that validate only the current step's fields, localStorage persistence so users can return later, and back navigation that never re-validates (no penalty for going back to review).
What we deliver:
STEP_SCHEMASarray: per-step Zod schemas (Step1/Step2/Step3)formReducer: NEXT_STEP marks complete, PREV_STEP no validation, GO_TO_STEP guards against skippinguseFormPersistence: mount restore from localStorage, save on every state change, clear on submitFormStepper:handleStepCompletevalidates current schema only, dispatches NEXT_STEP or submits- Step components: RHF + zodResolver with
defaultValuesfrom persisted state, Back/Next buttons StepIndicator: clickable completed steps, ring on current, connector lines
Talk to our team about your multi-step form requirements โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.