A well-designed multi-step form should manage form state globally, handle validation at each step, and dynamically render fields based on user input without losing entered data when moving between steps.
🧠 Key Concepts
- Centralized Form State → Store all step data in a single object (e.g., using
useStateoruseReducer). - Step-wise Navigation → Maintain a
currentStepindex. - Conditional Fields → Render inputs dynamically depending on previous answers.
- Validation → Run both synchronous and async validation before going to the next step.
- Async Validation → e.g., checking if an email is already taken.
🧾 Implementation
import React, { useState } from "react";
const steps = [
{ id: 1, fields: ["name", "email"] },
{ id: 2, fields: ["age", "gender"] },
{ id: 3, fields: ["country", "city"] },
];
export default function MultiStepForm() {
const [formData, setFormData] = useState({});
const [currentStep, setCurrentStep] = useState(0);
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const validateStep = async () => {
const fields = steps[currentStep].fields;
let newErrors = {};
for (const field of fields) {
if (!formData[field]) newErrors[field] = "This field is required";
}
// Example async validation for email
if (fields.includes("email") && formData.email) {
setLoading(true);
const emailTaken = await fakeEmailCheck(formData.email);
setLoading(false);
if (emailTaken) newErrors.email = "Email already taken";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = async () => {
const isValid = await validateStep();
if (isValid) setCurrentStep(prev => prev + 1);
};
const handlePrev = () => {
setCurrentStep(prev => prev - 1);
};
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const fakeEmailCheck = async (email) => {
await new Promise(res => setTimeout(res, 800));
return email === "test@example.com";
};
return (
<div>
<h2>Step {currentStep + 1}</h2>
{steps[currentStep].fields.map((field) => {
// Example of conditional field rendering
if (field === "city" && formData.country !== "India") return null;
return (
<div key={field}>
<label>{field.toUpperCase()}</label>
<input
name={field}
value={formData[field] || ""}
onChange={handleChange}
/>
{errors[field] && <p style={{ color: "red" }}>{errors[field]}</p>}
</div>
);
})}
{loading && <p>Validating...</p>}
<div>
{currentStep > 0 && <button onClick={handlePrev}>Back</button>}
{currentStep < steps.length - 1 && <button onClick={handleNext}>Next</button>}
{currentStep === steps.length - 1 && <button onClick={() => alert("Submitted!")}>Submit</button>}
</div>
</div>
);
}
🧭 Why This Works
- State Persistence: All form data is stored in one object, so you don’t lose info when navigating steps.
- Step Validation: Each step validates only its fields before moving forward.
- Async Validation: Handles things like checking availability of username/email.
- Conditional Rendering: “City” only shows up if “Country” is India.
- Reusability: Steps are defined in a simple config array.
🧪 Pro Tip
For larger forms, use libraries like:
- 🧰 Formik or React Hook Form to handle validation and state.
- 🧭 Yup for schema-based validation.
- 🧠 Context or Zustand/Redux for shared state across components.