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 step index and a single data object.
  • 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

  1. Add a phone field with formatting and validation.
  2. Persist form state to localStorage and restore it on reload.

Checks for Understanding

  1. When should you validate on change/blur vs on submit?
  2. Where should you store multi-step form state and why?
  3. What user feedback should be provided when a step is blocked due to validation?
  4. How do you ensure validation errors don’t linger once fields are corrected?
Answers
  1. 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.
  2. In a parent component (or a single state object) that persists across steps so that navigating back and forth doesn’t reset fields.
  3. Inline error messages near the fields, and clear, actionable messaging on the blocked action (e.g., disable Next or show which fields need attention).
  4. 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

  1. When should you validate on change/blur vs on submit?
  2. Where should you store multi-step form state and why?
  3. What user feedback should be provided when a step is blocked due to validation?
  4. How do you ensure validation errors don’t linger once fields are corrected?
Answers
  1. 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.
  2. In a parent component (or a single state object) that persists across steps so that navigating back and forth doesn’t reset fields.
  3. Inline error messages near the fields, and clear, actionable messaging on the blocked action (e.g., disable Next or show which fields need attention).
  4. 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).