Back to Blog

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.

Viprasol Tech Team
June 8, 2027
12 min read

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

ScopeTeamTimelineCost Range
3-step form with per-step validation1 dev2โ€“3 days$600โ€“1,200
+ localStorage persistence + restore1 dev1 day$300โ€“600
+ Animated step transitions + skip logic1 dev1 day$300โ€“600

See Also


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_SCHEMAS array: per-step Zod schemas (Step1/Step2/Step3)
  • formReducer: NEXT_STEP marks complete, PREV_STEP no validation, GO_TO_STEP guards against skipping
  • useFormPersistence: mount restore from localStorage, save on every state change, clear on submit
  • FormStepper: handleStepComplete validates current schema only, dispatches NEXT_STEP or submits
  • Step components: RHF + zodResolver with defaultValues from 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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.