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.
In this guide
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.
- Users only see the fields relevant to their current task
- Progress bars create a commitment effect — people want to finish
- You can validate and surface errors one section at a time
- Mobile users especially benefit from shorter, focused screens
- You can gate later steps on earlier answers (conditional logic)
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.
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.
3. Step navigation logic
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.
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:
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:
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.
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.
"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
- All form data lives in a single
INITIALobject — one payload for submission - Validation only runs for the fields belonging to the current step
- The submit handler doubles as the "next" handler for earlier steps
- The review step is read-only — no inputs, just a summary table
- The success screen replaces the entire form to confirm completion
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.
Related articles
React Form Validation — The Complete Guide Without Libraries
Per-field validation, real-time feedback, and custom rules explained.
React Contact Form — Build, Validate & Collect Submissions
Build a production-ready contact form with validation and submission handling.
React Form with TypeScript — Type-Safe Forms Complete Guide
Type your form state, onChange handlers, and validation errors with TypeScript.
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 →
