ReactForm
Blog/Tutorial

React Multi-Step Form — Build a Wizard with Progress Bar (2026)

Multi-step forms reduce drop-off by breaking a long form into focused steps. This guide walks through building a production-ready wizard in React — with a progress bar, per-step validation, and back/next navigation — entirely from scratch with no extra libraries.

ReactForm Team··12 min read

A checkout flow, an onboarding wizard, a job application — whenever a form has more than five or six fields, splitting it into multiple steps dramatically improves completion rates. Users can focus on one topic at a time, the interface feels less overwhelming, and you can validate each section before asking for the next piece of information.

This guide builds a three-step registration wizard: personal details, account details, and a review-and-submit step. The same pattern scales to any number of steps.

1. Why multi-step forms convert better

Long single-page forms trigger what UX researchers call "form anxiety" — users see the full scope of what is required and abandon before they start. Studies from Hubspot and Typeform consistently show that breaking a form into three to five steps increases conversion by 20–40% compared to a single long page.

2. Structuring the form state

The key insight for multi-step forms is that all step data lives in a single top-level state object. You never split it across local component state. This makes the final submission trivial — you already have everything in one place.

jsx
import { useState } from "react";

const INITIAL_DATA = {
  // Step 1 — Personal details
  firstName: "",
  lastName: "",
  email: "",

  // Step 2 — Account details
  username: "",
  password: "",
  confirmPassword: "",

  // Step 3 is review-only — no new fields
};

export default function MultiStepForm() {
  const [step, setStep] = useState(0);
  const [data, setData] = useState(INITIAL_DATA);
  const [errors, setErrors] = useState({});

  const updateFields = (fields) => {
    setData((prev) => ({ ...prev, ...fields }));
  };

  // steps array defined below
}

updateFields is a shared helper passed down to each step component. It merges a partial update into the top-level data object, so each step only needs to deal with its own slice of state.

Keep navigation simple: a step index, a next() function that validates the current step before advancing, and a back() function that always works without validation.

jsx
const STEPS = ["Personal", "Account", "Review"];

const next = () => {
  const errs = validateStep(step, data);
  if (Object.keys(errs).length > 0) {
    setErrors(errs);
    return;
  }
  setErrors({});
  setStep((s) => Math.min(s + 1, STEPS.length - 1));
};

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

const isLastStep = step === STEPS.length - 1;
const isFirstStep = step === 0;

Rendering is just a switch on the current step index. Each step is its own component that receives the shared data and updateFields props:

jsx
function renderStep() {
  switch (step) {
    case 0: return <StepPersonal data={data} onChange={updateFields} errors={errors} />;
    case 1: return <StepAccount data={data} onChange={updateFields} errors={errors} />;
    case 2: return <StepReview data={data} />;
    default: return null;
  }
}

return (
  <div className="max-w-lg mx-auto p-6">
    <ProgressBar step={step} total={STEPS.length} labels={STEPS} />
    <form onSubmit={isLastStep ? handleSubmit : (e) => { e.preventDefault(); next(); }}>
      {renderStep()}
      <div className="flex justify-between mt-8">
        {!isFirstStep && (
          <button type="button" onClick={back}
            className="text-sm text-gray-500 hover:text-gray-700 px-4 py-2 rounded-xl border border-gray-200 transition">
            Back
          </button>
        )}
        <button type="submit"
          className="ml-auto bg-teal-600 text-white font-semibold px-6 py-2.5 rounded-xl hover:bg-teal-700 transition text-sm">
          {isLastStep ? "Submit" : "Next →"}
        </button>
      </div>
    </form>
  </div>
);

4. Adding a progress bar

A progress bar is one line of arithmetic. The filled portion is (step / (total - 1)) * 100. Display it with step labels above for extra clarity:

jsx
function ProgressBar({ step, total, labels }) {
  const pct = (step / (total - 1)) * 100;

  return (
    <div className="mb-8">
      {/* Labels */}
      <div className="flex justify-between mb-2">
        {labels.map((label, i) => (
          <span
            key={label}
            className={`text-xs font-medium ${
              i <= step ? "text-teal-600" : "text-gray-400"
            }`}
          >
            {label}
          </span>
        ))}
      </div>

      {/* Track */}
      <div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
        <div
          className="h-full bg-teal-500 rounded-full transition-all duration-300 ease-out"
          style={{ width: `${pct}%` }}
        />
      </div>

      {/* Step dots */}
      <div className="flex justify-between mt-2">
        {labels.map((_, i) => (
          <div
            key={i}
            className={`w-3 h-3 rounded-full border-2 transition-all ${
              i < step
                ? "bg-teal-500 border-teal-500"
                : i === step
                ? "bg-white border-teal-500 shadow-sm shadow-teal-200"
                : "bg-white border-gray-200"
            }`}
          />
        ))}
      </div>
    </div>
  );
}

The step dots give users a spatial sense of where they are. Completed steps fill solid teal, the current step has a teal ring, and future steps stay grey.

5. Per-step validation

Validate only the fields that belong to the current step. Running all validations on every next-click would surface errors for fields the user has not seen yet.

jsx
const validateStep = (step, data) => {
  const errs = {};

  if (step === 0) {
    if (!data.firstName.trim()) errs.firstName = "First name is required";
    if (!data.lastName.trim()) errs.lastName = "Last name is required";
    if (!data.email.trim()) errs.email = "Email is required";
    else if (!/S+@S+.S+/.test(data.email)) errs.email = "Enter a valid email";
  }

  if (step === 1) {
    if (!data.username.trim()) errs.username = "Username is required";
    else if (data.username.length < 3) errs.username = "At least 3 characters";
    if (!data.password) errs.password = "Password is required";
    else if (data.password.length < 8) errs.password = "At least 8 characters";
    if (data.password !== data.confirmPassword)
      errs.confirmPassword = "Passwords do not match";
  }

  return errs;
};

Pass errors down to each step component and display them inline beneath the relevant field. Clear an individual field's error as soon as the user edits it so errors do not linger after correction.

6. Complete working example

Here is the full three-step registration wizard assembled. Copy this into your project — it has no dependencies beyond React and Tailwind CSS.

jsx
"use client";
import { useState } from "react";

// ── Validation ────────────────────────────────────────────────────────────────
const validateStep = (step, data) => {
  const errs = {};
  if (step === 0) {
    if (!data.firstName.trim()) errs.firstName = "Required";
    if (!data.lastName.trim())  errs.lastName  = "Required";
    if (!data.email.trim())     errs.email     = "Required";
    else if (!/S+@S+.S+/.test(data.email)) errs.email = "Invalid email";
  }
  if (step === 1) {
    if (!data.username.trim())  errs.username  = "Required";
    else if (data.username.length < 3) errs.username = "Min 3 characters";
    if (!data.password)         errs.password  = "Required";
    else if (data.password.length < 8) errs.password = "Min 8 characters";
    if (data.password !== data.confirmPassword)
      errs.confirmPassword = "Passwords do not match";
  }
  return errs;
};

// ── Reusable input ────────────────────────────────────────────────────────────
function Field({ label, error, children }) {
  return (
    <div>
      <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
      {children}
      {error && <p className="text-red-500 text-xs mt-1">{error}</p>}
    </div>
  );
}

function Input({ error, ...props }) {
  return (
    <input
      {...props}
      className={`w-full border rounded-xl px-3 py-2.5 text-sm outline-none transition ${
        error
          ? "border-red-400 focus:ring-1 focus:ring-red-400"
          : "border-gray-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500"
      }`}
    />
  );
}

// ── Steps ─────────────────────────────────────────────────────────────────────
function StepPersonal({ data, onChange, errors }) {
  return (
    <div className="flex flex-col gap-4">
      <h2 className="text-lg font-bold text-gray-800">Personal details</h2>
      <div className="grid grid-cols-2 gap-4">
        <Field label="First name" error={errors.firstName}>
          <Input
            type="text"
            value={data.firstName}
            error={errors.firstName}
            onChange={(e) => onChange({ firstName: e.target.value })}
            placeholder="Jane"
          />
        </Field>
        <Field label="Last name" error={errors.lastName}>
          <Input
            type="text"
            value={data.lastName}
            error={errors.lastName}
            onChange={(e) => onChange({ lastName: e.target.value })}
            placeholder="Smith"
          />
        </Field>
      </div>
      <Field label="Email" error={errors.email}>
        <Input
          type="email"
          value={data.email}
          error={errors.email}
          onChange={(e) => onChange({ email: e.target.value })}
          placeholder="jane@example.com"
        />
      </Field>
    </div>
  );
}

function StepAccount({ data, onChange, errors }) {
  return (
    <div className="flex flex-col gap-4">
      <h2 className="text-lg font-bold text-gray-800">Account details</h2>
      <Field label="Username" error={errors.username}>
        <Input
          type="text"
          value={data.username}
          error={errors.username}
          onChange={(e) => onChange({ username: e.target.value })}
          placeholder="janesmith"
        />
      </Field>
      <Field label="Password" error={errors.password}>
        <Input
          type="password"
          value={data.password}
          error={errors.password}
          onChange={(e) => onChange({ password: e.target.value })}
          placeholder="Min 8 characters"
        />
      </Field>
      <Field label="Confirm password" error={errors.confirmPassword}>
        <Input
          type="password"
          value={data.confirmPassword}
          error={errors.confirmPassword}
          onChange={(e) => onChange({ confirmPassword: e.target.value })}
          placeholder="Repeat password"
        />
      </Field>
    </div>
  );
}

function StepReview({ data }) {
  const rows = [
    { label: "Name",     value: `${data.firstName} ${data.lastName}` },
    { label: "Email",    value: data.email },
    { label: "Username", value: data.username },
  ];
  return (
    <div className="flex flex-col gap-4">
      <h2 className="text-lg font-bold text-gray-800">Review & submit</h2>
      <p className="text-sm text-gray-500">Please confirm your details before submitting.</p>
      <div className="rounded-xl border border-gray-100 divide-y divide-gray-100">
        {rows.map(({ label, value }) => (
          <div key={label} className="flex justify-between px-4 py-3 text-sm">
            <span className="text-gray-500">{label}</span>
            <span className="font-medium text-gray-800">{value}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Progress bar ──────────────────────────────────────────────────────────────
function ProgressBar({ step, total, labels }) {
  const pct = (step / (total - 1)) * 100;
  return (
    <div className="mb-8">
      <div className="flex justify-between mb-2">
        {labels.map((l, i) => (
          <span key={l} className={`text-xs font-medium ${i <= step ? "text-teal-600" : "text-gray-400"}`}>
            {l}
          </span>
        ))}
      </div>
      <div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
        <div className="h-full bg-teal-500 rounded-full transition-all duration-300 ease-out"
          style={{ width: `${pct}%` }} />
      </div>
    </div>
  );
}

// ── Main wizard ───────────────────────────────────────────────────────────────
const STEPS = ["Personal", "Account", "Review"];
const INITIAL = { firstName: "", lastName: "", email: "",
                  username: "", password: "", confirmPassword: "" };

export default function MultiStepForm() {
  const [step, setStep]     = useState(0);
  const [data, setData]     = useState(INITIAL);
  const [errors, setErrors] = useState({});
  const [done, setDone]     = useState(false);

  const updateFields = (fields) => setData((p) => ({ ...p, ...fields }));

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

  const back = () => { setErrors({}); setStep((s) => s - 1); };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (step < STEPS.length - 1) { next(); return; }
    console.log("Submitted:", data);
    setDone(true);
  };

  if (done) {
    return (
      <div className="max-w-lg mx-auto p-8 text-center flex flex-col items-center gap-4">
        <div className="w-14 h-14 bg-teal-500 rounded-full flex items-center justify-center">
          <svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
          </svg>
        </div>
        <h2 className="text-xl font-bold text-gray-800">Account created!</h2>
        <p className="text-sm text-gray-500">Welcome, {data.firstName}. Your account is ready.</p>
      </div>
    );
  }

  return (
    <div className="max-w-lg mx-auto bg-white border border-gray-100 rounded-2xl shadow-sm p-8">
      <ProgressBar step={step} total={STEPS.length} labels={STEPS} />
      <form onSubmit={handleSubmit}>
        {step === 0 && <StepPersonal data={data} onChange={updateFields} errors={errors} />}
        {step === 1 && <StepAccount  data={data} onChange={updateFields} errors={errors} />}
        {step === 2 && <StepReview   data={data} />}

        <div className="flex justify-between mt-8">
          {step > 0 && (
            <button type="button" onClick={back}
              className="text-sm text-gray-500 hover:text-gray-700 px-4 py-2 rounded-xl border border-gray-200 transition">
              Back
            </button>
          )}
          <button type="submit"
            className="ml-auto bg-teal-600 text-white font-semibold px-6 py-2.5 rounded-xl hover:bg-teal-700 transition text-sm">
            {step === STEPS.length - 1 ? "Create account" : "Next →"}
          </button>
        </div>
      </form>
    </div>
  );
}

Key decisions in this example

7. Build it visually — no code needed

If you need a multi-step form fast and do not want to manage navigation state, validation logic, and UI from scratch, ReactForm.co has you covered. The visual builder lets you organise fields into sections, add conditional logic between steps, and publish the form with a shareable link — or export clean React code to drop into your project.

Response collection, email notifications, and a submission dashboard are all included on the free plan. No backend required.

Build your multi-step form without the setup

Drag fields onto the canvas, group them into steps, add conditional logic, and publish with a shareable link. Free plan, no credit card required.

Open the Form Builder →