Multi-step form in React and shadcn with validation per step
A good multi-step form keeps every answer in one state object, validates only the current step when the visitor presses Continue, lets them go back without losing anything, and checks everything again before it submits once at the end. Below is that pattern in plain React, then two free multi-step forms from mebius.design that already do it, installed with the shadcn CLI.
The pattern
Six rules that keep a long form from losing people.
- One values object for the whole form, never one per step: Back and Edit links then cost nothing.
- Each step lists its fields. Continue validates those fields only; Back never validates.
- The last step validates every field again and, if something is wrong, returns to the first step with a problem.
- An invalid field gets aria-invalid, a message linked with aria-describedby, and the first one takes focus.
- A message clears as soon as its field changes, not on the next submit.
- Submitting shows a pending state and keeps the form on an error, so nothing typed is lost.
A minimal version in plain React
No form library: about 90 lines you can adapt.
Three steps (account, plan, review) with a validator that takes the fields to check. The same function serves Continue (one step’s fields) and the final submit (all of them):
"use client"
import * as React from "react"
type Values = { name: string; email: string; plan: "" | "starter" | "team"; seats: number }
type Field = keyof Values
type Errors = Partial<Record<Field, string>>
const steps: { title: string; fields: Field[] }[] = [
{ title: "Account", fields: ["name", "email"] },
{ title: "Plan", fields: ["plan", "seats"] },
{ title: "Review", fields: [] },
]
function validate(values: Values, fields: Field[]): Errors {
const errors: Errors = {}
if (fields.includes("name") && !values.name.trim()) errors.name = "Enter your name."
if (fields.includes("email") && !/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter a valid email address."
if (fields.includes("plan") && !values.plan) errors.plan = "Pick a plan."
if (fields.includes("seats") && !(Number.isInteger(values.seats) && values.seats >= 1)) errors.seats = "At least one seat."
return errors
}
export function SignupWizard({ onSubmit }: { onSubmit: (values: Values) => Promise<void> }) {
const [step, setStep] = React.useState(0)
const [values, setValues] = React.useState<Values>({ name: "", email: "", plan: "", seats: 1 })
const [errors, setErrors] = React.useState<Errors>({})
const [status, setStatus] = React.useState<"idle" | "sending" | "error" | "sent">("idle")
const form = React.useRef<HTMLFormElement>(null)
function set<K extends Field>(key: K, value: Values[K]) {
setValues((current) => ({ ...current, [key]: value }))
setErrors((current) => ({ ...current, [key]: undefined }))
}
const field = (name: Field) => ({
id: name,
name,
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? `${name}-error` : undefined,
className: "h-9 rounded-md border px-3 aria-invalid:border-red-500",
})
const message = (name: Field) =>
errors[name] ? <p id={`${name}-error`} className="text-sm text-red-600">{errors[name]}</p> : null
async function next(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const last = step === steps.length - 1
const found = validate(values, last ? steps.flatMap((s) => s.fields) : steps[step].fields)
setErrors(found)
const invalid = Object.keys(found) as Field[]
if (invalid.length > 0) {
setStep(steps.findIndex((s) => s.fields.some((f) => found[f])))
requestAnimationFrame(() => form.current?.querySelector<HTMLElement>(`[name="${invalid[0]}"]`)?.focus())
return
}
if (!last) {
setStep(step + 1)
return
}
setStatus("sending")
try {
await onSubmit(values)
setStatus("sent")
} catch {
setStatus("error")
}
}
if (status === "sent") return <p role="status">Thanks, {values.name}. Check your inbox.</p>
return (
<form ref={form} onSubmit={next} noValidate className="flex max-w-sm flex-col gap-4">
<p aria-live="polite" className="text-sm">
Step {step + 1} of {steps.length}: {steps[step].title}
</p>
{step === 0 && (
<>
<label htmlFor="name">Name</label>
<input {...field("name")} value={values.name} onChange={(e) => set("name", e.target.value)} />
{message("name")}
<label htmlFor="email">Email</label>
<input {...field("email")} type="email" value={values.email} onChange={(e) => set("email", e.target.value)} />
{message("email")}
</>
)}
{step === 1 && (
<>
<label htmlFor="plan">Plan</label>
<select {...field("plan")} value={values.plan} onChange={(e) => set("plan", e.target.value as Values["plan"])}>
<option value="">Choose…</option>
<option value="starter">Starter</option>
<option value="team">Team</option>
</select>
{message("plan")}
<label htmlFor="seats">Seats</label>
<input {...field("seats")} type="number" min={1} value={values.seats} onChange={(e) => set("seats", e.target.valueAsNumber)} />
{message("seats")}
</>
)}
{step === 2 && (
<p>
{values.name} ({values.email}), {values.plan} plan, {values.seats} seat(s).
</p>
)}
{status === "error" && <p className="text-sm text-red-600">Couldn’t sign you up. Try again.</p>}
<div className="flex gap-2">
<button type="button" disabled={step === 0} onClick={() => setStep(step - 1)}>
Back
</button>
<button type="submit" disabled={status === "sending"}>
{step < steps.length - 1 ? "Continue" : status === "sending" ? "Sending…" : "Sign up"}
</button>
</div>
</form>
)
}
Already on React Hook Form? Its trigger(fields) validates a list of fields and resolves to true when they pass, so Continue calls it with the current step’s fields before moving on. With Zod, give each step its own schema and validate the merged schema on submit.
Ready-made: the New Project form
Four steps in split panes, free with a mebius.design account.
The New Project Form follows the pattern above: Details, Repository, Environment and Review, with a steps pane that ticks off finished steps, Edit links on the review step and an async submit that keeps the form open on an error. Add the @mebius registry once (setup guide), then:
npx shadcn@latest add @mebius/forms-theme
npx shadcn@latest add @mebius/forms-new-project --overwriteOwn the step when you want a URL per step or analytics, and pass your own teams, repositories and submit handler:
"use client"
import * as React from "react"
import { NewProjectForm, type NewProjectFormValues, type NewProjectStep, type NewProjectTeam } from "@/components/new-project-form"
const teams: NewProjectTeam[] = [
{ value: "acme", label: "Acme Inc.", plan: { name: "Starter plan", used: 4, limit: 5 } },
{ value: "acme-labs", label: "Acme Labs", plan: { name: "Scale plan", used: 31 } },
]
async function createProject(values: NewProjectFormValues) {
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
// A rejected promise keeps the form open and shows its error line.
if (!response.ok) throw new Error(`Create failed: ${response.status}`)
}
export function CreateProject({ onClose }: { onClose: () => void }) {
const [step, setStep] = React.useState<NewProjectStep>("details")
React.useEffect(() => {
window.history.replaceState(null, "", `#${step}`)
}, [step])
return (
<NewProjectForm
teams={teams}
step={step}
onStepChange={setStep}
onSubmit={createProject}
onCancel={onClose}
onClose={onClose}
/>
)
}
Ready-made: the Paint a Wall form
A playful four-step application with a calendar and a file upload.
The Paint a Wall Form is a muralists’ open call: artist, wall, dates and sketch. Continue checks the current step and shakes the form when something is missing; the last step sends a FormData request with the sketch file:
"use client"
import { MuralBookingForm, type MuralBookingFormValues } from "@/components/mural-booking-form"
async function apply(values: MuralBookingFormValues) {
const body = new FormData()
body.set("artist", values.artist)
body.set("wall", values.wallId)
body.set("start", values.start)
body.set("days", String(values.days))
body.set("crew", String(values.crew))
if (values.sketch?.file) body.set("sketch", values.sketch.file)
const response = await fetch("/api/open-call", { method: "POST", body })
if (!response.ok) throw new Error("Application failed")
}
export function OpenCall() {
return <MuralBookingForm minDate="2026-12-01" maxDate="2027-02-28" maxDays={14} onSubmit={apply} />
}
It installs as @mebius/forms-pro-mural-booking after @mebius/forms-pro-theme. Both forms follow your app’s dark class.
Related
Item pages, kits and guides this guide uses.