React - Advanced Forms (Multi-step & Validation)
Overview
Estimated time: 30–45 minutes
Create a multi-step form wizard with per-step validation, live field feedback, and a final review + submit step. This mirrors common production patterns for onboarding and checkout flows.
Learning Objectives
- Build multi-step navigation with persistent form state across steps.
- Validate fields on change/blur and on step submission.
- Provide a review step and finalize submission safely.
Prerequisites
- Controlled inputs and basic validation.
Try it: Multi-step Wizard
View source
const { useState } = React;
function required(x){ return x ? '' : 'This field is required'; }
function emailRule(x){ if (!x) return 'Email is required'; return /.+@.+\..+/.test(x) ? '' : 'Enter a valid email'; }
function MultiStep(){
  const [step, setStep] = useState(1);
  const [data, setData] = useState({ name:'', email:'', address:'', city:'' });
  const [errors, setErrors] = useState({});
  function setField(key, val, rule){
    setData(d => ({ ...d, [key]: val }));
    if (rule){ setErrors(e => ({ ...e, [key]: rule(val) })); }
  }
  function validateStep(s){
    const e = {};
    if (s===1){ e.name = required(data.name); e.email = emailRule(data.email); }
    if (s===2){ e.address = required(data.address); e.city = required(data.city); }
    Object.keys(e).forEach(k => e[k] === '' && delete e[k]);
    setErrors(prev => ({ ...prev, ...e }));
    return Object.keys(e).length === 0;
  }
  function next(){ if (validateStep(step)) setStep(step+1); }
  function back(){ setStep(step-1); }
  async function submit(){
    if (!validateStep(2)) { setStep(2); return; }
    alert('Submitted: ' + JSON.stringify(data, null, 2));
  }
  return (
    <div>
      <div style={{marginBottom:8}}>Step {step} of 3</div>
      {step===1 && (
        <div>
          <label>Name: <input value={data.name} onChange={e => setField('name', e.target.value, required)} onBlur={() => setField('name', data.name, required)} /></label>
          {errors.name && <div style={{color:'salmon'}}>{errors.name}</div>}
          <br />
          <label>Email: <input value={data.email} onChange={e => setField('email', e.target.value, emailRule)} onBlur={() => setField('email', data.email, emailRule)} /></label>
          {errors.email && <div style={{color:'salmon'}}>{errors.email}</div>}
          <div style={{marginTop:8}}>
            <button onClick={next}>Next</button>
          </div>
        </div>
      )}
      {step===2 && (
        <div>
          <label>Address: <input value={data.address} onChange={e => setField('address', e.target.value, required)} onBlur={() => setField('address', data.address, required)} /></label>
          {errors.address && <div style={{color:'salmon'}}>{errors.address}</div>}
          <br />
          <label>City: <input value={data.city} onChange={e => setField('city', e.target.value, required)} onBlur={() => setField('city', data.city, required)} /></label>
          {errors.city && <div style={{color:'salmon'}}>{errors.city}</div>}
          <div style={{marginTop:8}}>
            <button onClick={back}>Back</button>
            <button onClick={next} style={{marginLeft:8}}>Next</button>
          </div>
        </div>
      )}
      {step===3 && (
        <div>
          <h4>Review</h4>
          <pre style={{whiteSpace:'pre-wrap'}}>{JSON.stringify(data, null, 2)}</pre>
          <div style={{marginTop:8}}>
            <button onClick={back}>Back</button>
            <button onClick={submit} style={{marginLeft:8}}>Submit</button>
          </div>
        </div>
      )}
    </div>
  );
}
ReactDOM.createRoot(document.getElementById('try-forms-adv')).render(<MultiStep />);
Syntax primer
- Wizard state: store a stepindex and a singledataobject.
- Validation helpers return an empty string for OK and a message for errors.
- Validate on change/blur for live feedback and on step submit for safety.
Common pitfalls
- Resetting step-local state on navigation—persist it in a parent component.
- Blocking navigation without telling the user why—show inline error messages.
Exercises
- Add a phone field with formatting and validation.
- Persist form state to localStorage and restore it on reload.
Checks for Understanding
- When should you validate on change/blur vs on submit?
- Where should you store multi-step form state and why?
- What user feedback should be provided when a step is blocked due to validation?
- How do you ensure validation errors don’t linger once fields are corrected?
Answers
- Use on change/blur for immediate feedback and on submit/step change as a safety gate. Combining both gives responsive UX and prevents proceeding with invalid data.
- In a parent component (or a single state object) that persists across steps so that navigating back and forth doesn’t reset fields.
- Inline error messages near the fields, and clear, actionable messaging on the blocked action (e.g., disable Next or show which fields need attention).
- Re-run the validation rule when the field changes/blur and clear the error when the rule passes (e.g., return empty string or remove the key).
Checks for Understanding
- When should you validate on change/blur vs on submit?
- Where should you store multi-step form state and why?
- What user feedback should be provided when a step is blocked due to validation?
- How do you ensure validation errors don’t linger once fields are corrected?
Answers
- Use on change/blur for immediate feedback and on submit/step change as a safety gate. Combining both gives responsive UX and prevents proceeding with invalid data.
- In a parent component (or a single state object) that persists across steps so that navigating back and forth doesn’t reset fields.
- Inline error messages near the fields, and clear, actionable messaging on the blocked action (e.g., disable Next or show which fields need attention).
- Re-run the validation rule when the field changes/blur and clear the error when the rule passes (e.g., return empty string or remove the key).