EmpireUI
Get Pro
← Blog9 min read#multi-step form#wizard#react

Multi-Step Form in React: Progress, Validation, State Persistence

Build a multi-step form in React with per-step validation, a live progress bar, and localStorage state persistence — no library required.

developer writing a multi-step form in a modern code editor

Why Multi-Step Forms Still Trip People Up

Single-page forms work fine for 3 fields. Push it past 10 and conversion rates fall off a cliff — users see a wall of inputs and bail. Splitting a form into steps isn't just cosmetic; it's a proven UX pattern that reduces perceived complexity and keeps people moving forward one decision at a time.

Honestly, the hard part isn't rendering different field groups. It's the orchestration: where does shared state live, how do you validate only the current step without blocking the user, and what happens when someone refreshes mid-flow? Those are the questions most tutorials skip.

This guide builds a real wizard from scratch using React 18 hooks — no react-hook-form, no formik, just useState, useReducer, and a bit of localStorage. You'll end up with something you actually understand and can adapt, rather than a dependency you're scared to touch.

Structuring State for a Wizard

The core mistake people make is storing form data inside each step component. Don't. All form data should live in a single parent — call it FormWizard — as one flat or nested object. Steps receive values as props and fire onChange callbacks. That's it.

Use useReducer if your form has more than ~8 fields or conditional logic. For simpler flows, useState with a single object is perfectly readable. Here's the baseline shape you'd start with:

type FormData = {
  // Step 1
  firstName: string;
  lastName: string;
  email: string;
  // Step 2
  plan: 'starter' | 'pro' | 'enterprise';
  billing: 'monthly' | 'annual';
  // Step 3
  cardNumber: string;
  expiry: string;
  cvv: string;
};

const INITIAL: FormData = {
  firstName: '', lastName: '', email: '',
  plan: 'starter', billing: 'monthly',
  cardNumber: '', expiry: '', cvv: '',
};

Worth noting: keep currentStep and errors outside of FormData. They're UI state, not submission payload. Mixing them in causes confusion when you go to serialize the final values.

The parent component drives navigation with a step integer (0-indexed internally, 1-indexed when displayed). Forward movement only fires after the current step passes validation. Back navigation is always free — you don't re-validate going backwards, that's just annoying.

Per-Step Validation Without a Library

You don't need Zod or Yup for a wizard. Write a plain validation function that takes the full FormData and the current step index, then returns an errors object. It's transparent, easy to test, and has zero bundle weight.

type Errors = Partial<Record<keyof FormData, string>>;

function validateStep(data: FormData, step: number): Errors {
  const errors: Errors = {};

  if (step === 0) {
    if (!data.firstName.trim()) errors.firstName = 'Required';
    if (!data.email.match(/^[^@]+@[^@]+\.[^@]+$/)) {
      errors.email = 'Enter a valid email';
    }
  }

  if (step === 1) {
    if (!data.plan) errors.plan = 'Pick a plan';
  }

  if (step === 2) {
    if (data.cardNumber.replace(/\s/g, '').length !== 16) {
      errors.cardNumber = 'Must be 16 digits';
    }
    if (!data.expiry.match(/^\d{2}\/\d{2}$/)) {
      errors.expiry = 'Format: MM/YY';
    }
    if (data.cvv.length < 3) errors.cvv = 'Too short';
  }

  return errors;
}

In practice, this is all you need for 90% of checkout flows. The next handler runs validateStep, sets errors in state, and only increments currentStep if the errors object is empty. Inline field errors appear instantly on first submit attempt per step — don't show them on every keystroke before the user has even had a chance to type.

One more thing — revalidate on onChange only after the user has already tried to advance. Set a touched flag when they hit Next, then watch fields live. That way you get real-time feedback without the hostile wall of red that appears before anyone types a character.

The Progress Bar Component

A progress bar does two jobs: it tells users how far they've come, and it gives them a sense of control. Keep it simple. A stepped indicator (dots or numbered circles) works better than a smooth bar for discrete wizard steps — the user can see exactly which steps exist, not just an abstract percentage.

type ProgressProps = {
  steps: string[];
  current: number;
};

export function StepProgress({ steps, current }: ProgressProps) {
  return (
    <div className="flex items-center gap-0">
      {steps.map((label, i) => (
        <>
          <div
            key={i}
            className={[
              'flex items-center justify-center w-9 h-9 rounded-full text-sm font-semibold transition-colors duration-200',
              i < current ? 'bg-indigo-600 text-white' : '',
              i === current ? 'bg-indigo-600 text-white ring-4 ring-indigo-200' : '',
              i > current ? 'bg-gray-100 text-gray-400' : '',
            ].join(' ')}
          >
            {i < current ? '✓' : i + 1}
          </div>
          {i < steps.length - 1 && (
            <div
              className={`h-0.5 flex-1 transition-colors duration-300 ${
                i < current ? 'bg-indigo-600' : 'bg-gray-200'
              }`}
            />
          )}
        </>
      ))}
    </div>
  );
}

The connector line between steps fills with color as the user advances. That 300ms transition-colors makes it feel alive without being distracting. Pair this with a subtle step label below each circle — "Account", "Plan", "Payment" — so users know what's coming at 32px font or smaller.

Quick aside: if your design system uses glassmorphism cards, the progress indicator reads much better when it sits outside the frosted panel, above it, rather than embedded inside. Check the glassmorphism components for card containers that already handle this layout cleanly.

Persisting State Across Refreshes with localStorage

Here's the scenario nobody designs for until a user rage-emails them: someone's halfway through a 5-step onboarding form, they accidentally close the tab, they come back, and everything's gone. That's a lost conversion and a bad experience. Fifteen lines of code fix it.

Write a custom hook that syncs form state to localStorage on every change and reads it back on mount. Debounce the writes slightly — you don't want a write on every single keypress:

import { useEffect, useCallback } from 'react';

const STORAGE_KEY = 'wizard_draft_v1';

export function usePersistedForm<T>(initial: T) {
  const [data, setData] = React.useState<T>(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY);
      return saved ? { ...initial, ...JSON.parse(saved) } : initial;
    } catch {
      return initial;
    }
  });

  // Debounced write
  useEffect(() => {
    const id = setTimeout(() => {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    }, 400);
    return () => clearTimeout(id);
  }, [data]);

  const clearDraft = useCallback(() => {
    localStorage.removeItem(STORAGE_KEY);
  }, []);

  return { data, setData, clearDraft };
}

Call clearDraft() on successful submission so returning users start fresh. Also version your storage key — wizard_draft_v1 — so a schema change doesn't cause a crash when old data gets deserialized into a different shape. Bump to v2 when you add or rename fields.

In practice, also persist currentStep. Users want to land back on the step they left, not restart from step 1 with their data pre-filled. That detail is the difference between helpful and merely functional.

Putting It All Together: The FormWizard Shell

Here's the full parent component wiring everything together. This is the real orchestration layer — validation, navigation, persistence, and rendering the right step:

const STEPS = ['Account', 'Plan', 'Payment', 'Review'];

export function FormWizard() {
  const { data, setData, clearDraft } = usePersistedForm(INITIAL);
  const [step, setStep] = React.useState(0);
  const [errors, setErrors] = React.useState<Errors>({});
  const [touched, setTouched] = React.useState(false);

  const update = (patch: Partial<FormData>) => {
    const next = { ...data, ...patch };
    setData(next);
    if (touched) setErrors(validateStep(next, step));
  };

  const handleNext = () => {
    setTouched(true);
    const errs = validateStep(data, step);
    setErrors(errs);
    if (Object.keys(errs).length === 0) {
      setStep((s) => s + 1);
      setTouched(false);
      setErrors({});
    }
  };

  const handleBack = () => {
    setStep((s) => Math.max(0, s - 1));
    setTouched(false);
    setErrors({});
  };

  const handleSubmit = async () => {
    // POST data, then:
    clearDraft();
  };

  const stepProps = { data, errors, onChange: update };

  return (
    <div className="max-w-lg mx-auto px-4 py-10">
      <StepProgress steps={STEPS} current={step} />
      <div className="mt-8">
        {step === 0 && <StepAccount {...stepProps} />}
        {step === 1 && <StepPlan {...stepProps} />}
        {step === 2 && <StepPayment {...stepProps} />}
        {step === 3 && <StepReview data={data} onSubmit={handleSubmit} />}
      </div>
      <div className="flex justify-between mt-6">
        {step > 0 && (
          <button onClick={handleBack} className="btn-secondary">Back</button>
        )}
        {step < STEPS.length - 1 && (
          <button onClick={handleNext} className="btn-primary ml-auto">Next</button>
        )}
      </div>
    </div>
  );
}

The touched flag is doing important work here. Before the user first hits Next on a step, errors are never shown. Once they try to advance, errors appear immediately — and then live-update as they fix fields. That's the exact right behavior and it requires zero extra libraries.

Notice that each step component gets a consistent { data, errors, onChange } interface. This makes them trivially testable in isolation. You can render <StepAccount /> in a test with mock props and assert on rendered errors without needing the full wizard mounted.

For the visual layer, something like the glassmorphism generator can help you produce the card backdrop styles for each step panel — that frosted-glass look works particularly well for multi-step checkout flows where you want a sense of depth and progression.

Accessibility, Animation, and Finishing Touches

Keyboard navigation needs explicit attention. Pressing Enter inside a step field should advance to the next step (or next field), not submit the whole form. Add an onKeyDown handler on each step's container that calls handleNext on Enter, but only if the focused element isn't a textarea. Wire autoFocus to the first field of each step so users don't have to click after advancing.

ARIA matters here too. The step container should carry role="group" and aria-labelledby pointing to the step title. The progress indicator should have aria-label="Step 2 of 4" so screen readers announce location. These two attributes alone cover the bulk of the accessibility requirement with maybe 10 minutes of work.

Step transitions are nice but optional. A simple opacity-0 → opacity-100 with translate-x-4 → translate-x-0 on a 150ms ease-out feels clean without being slow. If you go further than that — slide-ins, 3D flips, anything over 300ms — you'll frustrate mobile users and anyone who's filling this out in a hurry. Keep it subtle. Look at how the animation design system handles enter/exit timing as a reference.

Last thing: the Review step (the final step before submission) should display all entered values, not ask the user to scroll back through. Summarize clearly, let them edit inline if possible, and make the submit button obviously distinct — larger, different color, not the same btn-primary you've been using for Next.

FAQ

Should I use react-hook-form or formik for a multi-step wizard?

You can, but neither is designed for multi-step flows out of the box — you'll spend time fighting their defaults. A plain useReducer plus a validation function is less code and easier to debug for most wizard use cases.

How do I prevent users from jumping to a future step directly?

Don't render step circles as clickable links by default. If you want clickable back-navigation, only allow going to steps where i < currentStep — never forward to unvalidated steps.

What's the best way to handle async validation (e.g., checking if an email already exists)?

Run async checks in handleNext before incrementing the step. Set a validating boolean to show a spinner on the Next button, then set errors or advance based on the response.

How do I reset the form if the user wants to start over?

Call setData(INITIAL), setStep(0), setErrors({}), setTouched(false), and clearDraft() in a single reset handler. Optionally show a confirmation modal before wiping filled data.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Stepper / Wizard Progress in React: Linear, Non-Linear, BranchingStepper Component in React: Multi-Step Forms and OnboardingGlassmorphism Onboarding UI: Multi-Step Wizard With Frosted StepsReact Hook Form + Zod: The Form Stack That Finally Makes Sense