Multi-step React forms split one long form into screens while keeping a single values object and a step number in state. Each step validates its own fields before the user advances, and the values persist across steps because they live in one shared object. This guide builds a three-step signup and keeps the progress accessible.
Longer forms feel less intimidating when broken into a few short screens, and each step boundary gives a clear place to validate and correct mistakes.
Track the step and the values
Two pieces of state drive the whole form: the current step number and one values object that every step writes into.
import { useState } from "react";
export default function SignupForm() {
const [step, setStep] = useState(1);
const [values, setValues] = useState({ name: "", email: "", plan: "" });
const [errors, setErrors] = useState({});
function handleChange(e) {
setValues({ ...values, [e.target.name]: e.target.value });
}
// the return below continues this same componentKeeping the values in one object means moving from step to step never loses what the user already typed. The step number only decides which fields are visible. handleChange uses the same one-handler pattern that works for any number of fields, so every field across every step can share it.
Render one step at a time
Show the current step with a conditional render. Each field keeps the same name and value pattern as a normal controlled input, so no extra wiring is needed per step. This return statement continues the SignupForm component above.
return (
<form onSubmit={handleSubmit}>
{step === 1 && (
<label>
Name
<input name="name" value={values.name} onChange={handleChange} />
</label>
)}
</form>
);Step one renders the name field, and steps two and three follow the same pattern with their own fields. Only the active step is in the DOM, which keeps the form short and avoids rendering every field at once.
Only visible controls exist in the DOM, so hidden steps are not submitted with the form.
Validate before advancing
Each step checks its own fields before the user can move on. A small function returns a message for the active step or null, and the Next button only advances when the step passes. Validating per step catches mistakes at the moment they are made, instead of dumping every error on the final screen.
function validateStep(step) {
if (step === 1 && !values.name.trim()) {
return "Enter your name.";
}
if (step === 2 && !values.email.includes("@")) {
return "Enter a valid email address.";
}
return null;
}The check for step one catches an empty name, and step two catches an email without an @ sign. Step three can require a selected plan with the same idea.
function handleNext() {
const error = validateStep(step);
if (error) {
setErrors({ step: error });
return;
}
setErrors({});
setStep(step + 1);
}When the active step is valid, the error clears and the step number increases. The Back button does not validate; it just steps down. The form's onSubmit points at a handleSubmit function that only runs on the final step; it revalidates every field the same way a normal submit handler does, then sends the completed values object.
Step three can require a selected plan, and the final submit revalidates every field together before sending.
Keep the progress accessible
Show the step buttons and a message, then move focus to the new step so keyboard and screen reader users know the screen changed.
{step > 1 && <button type="button" onClick={() => setStep(step - 1)}>Back</button>}
{step < 3 && <button type="button" onClick={handleNext}>Next</button>}
{step === 3 && <button type="submit">Submit</button>}
{errors.step && <p role="alert">{errors.step}</p>}The Back button is hidden on the first step, and the final step swaps Next for a real submit button. The alert paragraph announces any step error.
Add a visible step list above the fields and mark the active step with aria-current so screen readers announce the position. An ordered list works well, with each step as a list item and the current one labeled step.
Move focus to the new step heading after the step changes. Store a ref on the heading and call focus in the same handlers that change the step, so keyboard users are not stranded on a button that just disappeared.
The diagram shows the only legal moves: forward when a step validates, backward freely, and submit from the last step. The final submit still revalidates everything before sending, because client checks never replace server checks.
Keep the step number and values in one component, and split the step UI into child components when a single step grows large. The parent still owns the state and passes values plus handlers down.
For the field wiring, see how to build forms in React. For richer per-field validation, see React form validation without a library, and for the response side see how to show server validation errors in React forms.
Rune AI
Key Insights
- Keep step and values in separate state.
- Show one step with a conditional render.
- Validate the current step before Next.
- Keep all values in one object so steps share them.
- Mark the active step with aria-current and move focus.
Frequently Asked Questions
How do I keep values when the user moves between steps?
Should I validate each step or only on submit?
How do I make the progress indicator accessible?
Conclusion
A multi-step form is one values object plus a step number. Show one step at a time, validate before advancing, and keep the progress indicator accessible.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.