Problem Understanding
The most important architectural decision in a multi-step form is: where does the form data live? The answer is always: at the top level, in the parent component. Not inside each step component. Here's why:
// ❌ Wrong — data split across step components
function Step1() { const [name, setName] = useState(''); ... }
function Step2() { const [email, setEmail] = useState(''); ... }
// Problem: when Step1 unmounts (user clicks Next), its state is gone.
// Going Back shows empty fields. Review step can't access Step1's data.
// ✅ Correct — all data at the top level
function MultiStepForm() {
const [formData, setFormData] = useState({
firstName: '', lastName: '', email: '', // Step 1
username: '', password: '', // Step 2
});
// Step components receive formData as props — they never own state
}
With centralized formData, going back shows the values the user already entered. The review step can read all fields. Validation has access to all values at once. This is the only correct design.
💡 Interview Tip: Say this explicitly before coding: "I'm keeping all form data in a single object at the top level. Step components are purely controlled — they receive data as props and call an onChange handler. This means going back and forth never loses data, and the review step has access to everything."
State Shape
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({
firstName: '', lastName: '', email: '', // step 0
username: '', password: '', // step 1
// step 2 is review — no new fields
});
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
The onChange Handler
function handleChange(field, value) {
setFormData(prev => ({ ...prev, [field]: value })); // update one field
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' })); // clear error immediately
}
}
Clearing the error for a field as soon as the user starts typing gives instant visual feedback — the red error message disappears the moment they address it. This is a much better UX than clearing all errors only on the Next click.
Validation as a Pure Function
function validate(step, data) {
const errors = {};
if (step === 0) {
if (!data.firstName.trim()) errors.firstName = 'First name is required';
if (!data.lastName.trim()) errors.lastName = 'Last name is required';
if (!data.email.trim()) errors.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(data.email)) errors.email = 'Enter a valid email';
}
if (step === 1) {
if (!data.username.trim()) errors.username = 'Username is required';
if (data.password.length < 6) errors.password = 'Password must be at least 6 characters';
}
return errors; // empty object = valid
}
Extracting validation into a pure function (not inside a component) makes it independently testable and easy to extend. The function takes step and data, returns an errors object. An empty object means the step is valid. This pattern scales cleanly as you add more steps and more fields.
Next Button Logic
function handleNext() {
const stepErrors = validate(currentStep, formData);
if (Object.keys(stepErrors).length > 0) {
setErrors(stepErrors); // show errors
return; // don't advance
}
setErrors({}); // clear all errors
setCurrentStep(s => s + 1);
}
Step Rendering
const STEPS = ['Personal Info', 'Account Setup', 'Review'];
function renderStep() {
switch (currentStep) {
case 0: return <PersonalStep formData={formData} onChange={handleChange} errors={errors} />;
case 1: return <AccountStep formData={formData} onChange={handleChange} errors={errors} />;
case 2: return <ReviewStep formData={formData} />;
}
}
No React Router needed. A simple switch on currentStep renders the right component. The step components are stateless — they just receive props and render fields.
Step Indicator
function StepIndicator({ current, total }) {
return (
<div className="step-indicator">
{Array.from({ length: total }, (_, i) => (
<React.Fragment key={i}>
<div className={[
'step-circle',
i < current ? 'step-done' : '',
i === current ? 'step-active' : '',
].join(' ')}>
{i < current ? '✓' : i + 1}
</div>
{i < total - 1 && (
<div className={`step-line ${i < current ? 'step-line-done' : ''}`} />
)}
</React.Fragment>
))}
</div>
);
}
Three visual states per step: upcoming (gray number), active (highlighted number), completed (checkmark + filled). The connecting line between steps also fills as steps are completed.
Review Step — All Data Visible
function ReviewStep({ formData }) {
const fields = [
['First Name', formData.firstName],
['Last Name', formData.lastName],
['Email', formData.email],
['Username', formData.username],
['Password', '••••••••'], // never show actual password
];
return (
<div>
<h3>Review Your Details</h3>
{fields.map(([label, value]) => (
<div key={label} className="review-row">
<span className="review-label">{label}</span>
<span className="review-value">{value || '—'}</span>
</div>
))}
</div>
);
}
⚠️ Common Mistake: Displaying the actual password in the review step. Always mask it with '••••••••'. This is a security and UX expectation — the user has already set their password; they don't need to see it again.
Common Pitfalls Summary
- State inside step components: Unmounting a step destroys its local state. Centralize everything at the parent level.
- Not clearing errors on field change: Errors persist after the user has fixed the field — confusing and frustrating UX.
- Resetting currentStep on browser refresh: Acceptable in an interview, but mention that production apps would persist step progress in URL params or sessionStorage.
- Not masking the password on review: Security concern and bad UX.
- Putting validation logic inside step components: Makes it impossible to run validation from the parent's Next click handler. Keep validation at the parent level or in a pure utility function.
How to Communicate During the Interview
- "All form data lives in a single formData object at the top level. Step components are purely controlled — they never own state. This ensures going back never loses data."
- "Validation is a pure function that takes the current step and formData and returns an errors object. An empty object means valid. I call it on every Next click."
- "I clear a field's error the moment the user starts typing in that field — instant feedback is much better UX than waiting for the next Next click."
- "On the review step, I mask the password — you never display plaintext passwords back to the user."