Multi-Step Form
Long forms cause drop-off. Splitting them into steps keeps users focused on one group of questions at a time. This recipe shows a two-step React form that submits all data in a single Formtorch request at the end.
Plan your steps
Decide which fields belong to each step. A good rule of thumb: group by topic, keep each step to 3–5 fields.
For this example:
- Step 1: Name and email
- Step 2: Project details and budget
Build the multi-step component
components/MultiStepForm.tsx
"use client";
import { useState } from "react";
type FormData = {
name: string;
email: string;
projectType: string;
budget: string;
message: string;
};
const initialData: FormData = {
name: "",
email: "",
projectType: "",
budget: "",
message: "",
};
export function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<FormData>(initialData);
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
function update(field: keyof FormData, value: string) {
setFormData((prev) => ({ ...prev, [field]: value }));
}
function next(e: React.FormEvent) {
e.preventDefault();
setStep(2);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
const body = new FormData();
for (const [key, value] of Object.entries(formData)) {
body.append(key, value);
}
const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body,
});
setStatus(res.ok ? "success" : "error");
}
if (status === "success") {
return (
<div>
<h2>Request received</h2>
<p>We'll review your details and follow up within 2 business days.</p>
</div>
);
}
return (
<div>
{/* Progress indicator */}
<p>Step {step} of 2</p>
{step === 1 && (
<form onSubmit={next}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => update("name", e.target.value)}
required
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => update("email", e.target.value)}
required
/>
</div>
<button type="submit">Next</button>
</form>
)}
{step === 2 && (
<form onSubmit={submit}>
<div>
<label htmlFor="projectType">Project type</label>
<select
id="projectType"
value={formData.projectType}
onChange={(e) => update("projectType", e.target.value)}
required
>
<option value="">Select one</option>
<option value="website">Website</option>
<option value="mobile-app">Mobile app</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label htmlFor="budget">Budget range</label>
<select
id="budget"
value={formData.budget}
onChange={(e) => update("budget", e.target.value)}
required
>
<option value="">Select one</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k – $20k</option>
<option value="20k-plus">$20k+</option>
</select>
</div>
<div>
<label htmlFor="message">Additional details</label>
<textarea
id="message"
value={formData.message}
onChange={(e) => update("message", e.target.value)}
rows={4}
/>
</div>
{status === "error" && (
<p role="alert">Something went wrong. Please try again.</p>
)}
<button type="button" onClick={() => setStep(1)}>
Back
</button>
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Submitting…" : "Submit request"}
</button>
</form>
)}
</div>
);
}Use the component
app/quote/page.tsx
import { MultiStepForm } from "@/components/MultiStepForm";
export default function QuotePage() {
return (
<main>
<h1>Request a quote</h1>
<MultiStepForm />
</main>
);
}Key points
- All field data is held in React state, not in separate forms.
- Only one
fetchcall happens: at the final step on submit. - The
Backbutton reverts to step 1 without losing entered data. - Native form validation (
required) still works per-step because each step has its own<form>element.
For more than 2 steps, extract the step-rendering logic into a steps array and iterate over it. The submit-at-the-end pattern stays the same regardless of how many steps you add.
Last updated on