DiegoVallejo

Stop Building Form Monsters: A Battle-Tested Guide to Sequential UX

The Problem: The "Death Scroll" Component

Let's be real: monolithic forms are technical debt masquerading as a feature. When you shove 40 fields into one component, you're creating a maintenance nightmare.

From a Technical perspective, your state management becomes a tangled mess of conditional logic. One change to a validation schema ripples through 2,000 lines of code. It's brittle, slow, and impossible to reason about.

From a UX/A11y perspective, it's a failure. Users hit "form fatigue." Screen reader users lose context. If a user makes a mistake on field #3, but doesn't find out until they hit "Submit" on field #40, that's a broken experience.

The Solution: Finite State Machine (FSM) Flow

The fix is treating the form as an orchestration of independent states. Each step is a standalone unit that validates its own scope before signaling the "Manager" to move forward.

The Strategy:

  1. Atomic Steps: Each UI view is a separate component.
  2. Centralized Store: A single source of truth for the aggregate data.
  3. Focus Management: Explicitly moving focus to the new header on every transition.
  4. Semantic Fieldsets: Using native elements to provide grouping context to assistive tech.

Technical Comparison

Feature Monolithic Form Sequential (FSM)
Logic Complexity Exponential Linear (O(1) per step)
Validation Batch / Late Scoped / Immediate
A11y Focus desert Managed focus anchors
Bundle Size Heavy initial load Lazy-loadable steps

Pro-Tip: Never rely on the index of an array to manage steps. Use unique string IDs. If product requirements change and Step 3 needs to become Step 1, moving an ID in a config object is trivial; refactoring index-based logic is a nightmare.


Anti-Pattern vs. Pattern: The Refactor

Nothing illustrates the problem better than side-by-side code. The anti-pattern is a single React component carrying the weight of the entire world.

The Anti-Pattern: The Monolith

// ❌ ANTI-PATTERN: MonolithicForm.tsx
// 40+ fields. One useState. One submit handler. One validation function.
// Change field #3? Pray nothing breaks on field #37.
export function MonolithicForm() {
  const [fields, setFields] = useState({
    name: '', email: '', phone: '', address: '', city: '',
    state: '', zip: '', country: '', dob: '', ssn: '',
    // ...30 more fields
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Validate ALL 40 fields at once.
    // If field #2 is invalid, does the user even see the error?
    if (!fields.name) return showError('name', 'Required');
    if (!fields.email.includes('@')) return showError('email', 'Invalid');
    // ...38 more validation conditions
    submitToServer(fields); // Finally.
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={fields.name} onChange={...} />
      <input name="email" value={fields.email} onChange={...} />
      {/* ...38 more inputs, all rendered at once */}
      <button type="submit">Submit</button>
    </form>
  );
}

This is a maintenance trap. The validation logic, the state shape, and the UI are fused into a single unit. You cannot test, reuse, or iterate on any one part without touching all three.

The Pattern: The Orchestrator

// ✅ PATTERN: FormOrchestrator.tsx
// The parent owns the state and the routing. Each child owns its own validation.
import { useFormState } from './core/useFormState';
import { IdentityStep } from './steps/IdentityStep';
import { SecurityStep } from './steps/SecurityStep';
import { ReviewStep } from './steps/ReviewStep';

export function FormOrchestrator() {
  const { currentStep, formData, next, back } = useFormState();

  return (
    <main>
      {currentStep === 'ID' && <IdentityStep onNext={data => next(data)} />}
      {currentStep === 'SEC' && <SecurityStep onBack={back} onNext={data => next(data)} />}
      {currentStep === 'REV' && <ReviewStep data={formData} onBack={back} onConfirm={reset} />}
    </main>
  );
}

Each step component is testable in complete isolation. The orchestrator has no knowledge of validation rules. Swap IdentityStep for a new version without touching a single line of SecurityStep.


Visualizing Progress: The Step Indicator

A multi-step form without a progress indicator is a maze with no map. The user needs to know where they are, where they've been, and how far they have to go. Use aria-current="step" to communicate the active step to assistive technologies — a visual-only solution is an A11y failure.

<nav aria-label="Form progress">
  <ol class="progress-steps">
    <li class="step step--complete">
      <span class="step__marker" aria-hidden="true">✓</span>
      <span class="step__label">Identity</span>
    </li>
    <li class="step step--active" aria-current="step">
      <span class="step__marker" aria-hidden="true">2</span>
      <span class="step__label">Security</span>
    </li>
    <li class="step step--upcoming">
      <span class="step__marker" aria-hidden="true">3</span>
      <span class="step__label">Review</span>
    </li>
  </ol>
</nav>

<style>
  body {
    font-family: system-ui, sans-serif;
    padding: 2rem;
    background: #f9f9f9;
  }
  .progress-steps {
    display: flex;
    list-style: none;
    margin: 0;
    padding: 0;
    gap: 0;
    align-items: center;
    max-width: 480px;
  }
  .step {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.5rem;
    flex: 1;
    position: relative;
  }
  /* Connector line between steps */
  .step:not(:last-child)::after {
    content: '';
    position: absolute;
    top: 20px;
    left: calc(50% + 20px);
    right: calc(-50% + 20px);
    height: 2px;
    background: #e0e0e0;
    z-index: 0;
  }
  .step--complete:not(:last-child)::after {
    background: #0066cc;
  }
  .step__marker {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 1rem;
    position: relative;
    z-index: 1;
    border: 2px solid #e0e0e0;
    background: #fff;
    color: #999;
  }
  .step--complete .step__marker {
    background: #0066cc;
    border-color: #0066cc;
    color: #fff;
  }
  .step--active .step__marker {
    background: #fff;
    border-color: #0066cc;
    color: #0066cc;
    box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.15);
  }
  .step__label {
    font-size: 0.8rem;
    font-weight: 500;
    color: #999;
  }
  .step--complete .step__label {
    color: #0066cc;
  }
  .step--active .step__label {
    color: #0066cc;
    font-weight: 700;
  }
</style>

Live Demo — Step Progress Indicator

Open in new tab ↗

The aria-current="step" attribute is the semantic anchor. Screen readers will announce something like "Security, step, 2 of 3" — which is exactly the spatial context a keyboard-only or screen-reader user needs. The CSS class step--active is purely visual; the ARIA attribute is the ground truth.


The Implementation: Form Examples

1. The Context Layer (Identity)

We use <fieldset> and <legend> to group related inputs. This ensures screen readers announce the group name when an input receives focus.

<section id="step-identity" aria-labelledby="id-title">
  <h2 id="id-title">Step 1: Your Profile</h2>
  <form id="form-identity" novalidate>
    <fieldset>
      <legend>Basic Information</legend>
      <div class="field">
        <label for="full-name">Full Name</label>
        <input type="text" id="full-name" name="name" required aria-describedby="hint-name" />
        <span id="hint-name" class="hint">Official name only.</span>
      </div>
    </fieldset>
    <button type="submit">Next Step</button>
  </form>
</section>

<style>
  section {
    font-family: system-ui, sans-serif;
    max-width: 400px;
    padding: 1rem;
  }
  h2 {
    color: #1a1a2e;
    margin-bottom: 1rem;
  }
  fieldset {
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 1rem;
  }
  legend {
    font-weight: 600;
    color: #333;
    padding: 0 0.5rem;
  }
  .field {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
  }
  label {
    font-weight: 500;
    color: #444;
  }
  input {
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
  }
  input:focus {
    outline: 2px solid #0066cc;
    border-color: #0066cc;
  }
  .hint {
    font-size: 0.875rem;
    color: #666;
  }
  button[type='submit'] {
    background: #0066cc;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }
  button[type='submit']:hover {
    background: #0052a3;
  }
</style>

Live Demo — Step 1: Identity

Open in new tab ↗

The React equivalent keeps the HTML structure identical but moves state into the component and replaces inline validation with a controlled pattern:

// IdentityStep.tsx
interface IdentityData {
  name: string;
}

interface IdentityStepProps {
  onNext: (data: IdentityData) => void;
}

export function IdentityStep({ onNext }: IdentityStepProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  // Move focus to the heading when this step mounts — critical for A11y.
  useEffect(() => {
    headingRef.current?.focus();
  }, []);

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    onNext({ name: data.get('name') as string });
  }

  return (
    <section aria-labelledby="id-title">
      <h2 id="id-title" tabIndex={-1} ref={headingRef}>
        Step 1: Your Profile
      </h2>
      <form onSubmit={handleSubmit} noValidate>
        <fieldset>
          <legend>Basic Information</legend>
          <div className="field">
            <label htmlFor="full-name">Full Name</label>
            <input type="text" id="full-name" name="name" required aria-describedby="hint-name" />
            <span id="hint-name" className="hint">
              Official name only.
            </span>
          </div>
        </fieldset>
        <button type="submit">Next Step</button>
      </form>
    </section>
  );
}

2. The Verification Layer (Security)

Here we implement dynamic error handling using aria-live. Note the type="button" on the "Back" button to prevent unintentional form submission.

<section id="step-security" aria-labelledby="sec-title">
  <h2 id="sec-title">Step 2: Account Security</h2>
  <form id="form-security" novalidate>
    <div role="alert" aria-live="polite" id="error-summary"></div>

    <div class="field">
      <label for="email">Work Email</label>
      <input type="email" id="email" name="email" required />
    </div>

    <div class="actions">
      <button type="button" class="btn-alt" onclick="goBack()">Go Back</button>
      <button type="submit">Confirm & Submit</button>
    </div>
  </form>
</section>

<style>
  section {
    font-family: system-ui, sans-serif;
    max-width: 400px;
    padding: 1rem;
  }
  h2 {
    color: #1a1a2e;
    margin-bottom: 1rem;
  }
  .field {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    margin-bottom: 1rem;
  }
  label {
    font-weight: 500;
    color: #444;
  }
  input {
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
  }
  input:focus {
    outline: 2px solid #0066cc;
    border-color: #0066cc;
  }
  input:invalid:not(:placeholder-shown) {
    border-color: #dc3545;
  }
  #error-summary:not(:empty) {
    background: #fee;
    border: 1px solid #dc3545;
    color: #dc3545;
    padding: 0.75rem;
    border-radius: 4px;
    margin-bottom: 1rem;
  }
  .actions {
    display: flex;
    gap: 0.5rem;
    justify-content: space-between;
  }
  .btn-alt {
    background: #f0f0f0;
    color: #333;
    border: 1px solid #ccc;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }
  .btn-alt:hover {
    background: #e0e0e0;
  }
  button[type='submit'] {
    background: #0066cc;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }
  button[type='submit']:hover {
    background: #0052a3;
  }
</style>

<script>
  function goBack() {
    document.getElementById('error-summary').textContent = 'Navigating back to previous step...';
  }

  document.getElementById('form-security').addEventListener('submit', function (e) {
    e.preventDefault();
    const email = document.getElementById('email').value;
    const errorSummary = document.getElementById('error-summary');

    if (!email || !email.includes('@')) {
      errorSummary.textContent = 'Please enter a valid work email address.';
    } else {
      errorSummary.textContent = '';
      alert('Form submitted successfully!');
    }
  });
</script>

Live Demo — Step 2: Security

Open in new tab ↗

The React version replaces the inline onclick and the imperative addEventListener with proper controlled state and event handlers:

// SecurityStep.tsx
interface SecurityData {
  email: string;
}

interface SecurityStepProps {
  onBack: () => void;
  onNext: (data: SecurityData) => void;
}

export function SecurityStep({ onBack, onNext }: SecurityStepProps) {
  const [error, setError] = useState('');
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, []);

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const email = data.get('email') as string;

    if (!email || !email.includes('@')) {
      setError('Please enter a valid work email address.');
      return;
    }

    setError('');
    onNext({ email });
  }

  return (
    <section aria-labelledby="sec-title">
      <h2 id="sec-title" tabIndex={-1} ref={headingRef}>
        Step 2: Account Security
      </h2>
      <form onSubmit={handleSubmit} noValidate>
        {/* role="alert" + aria-live ensures errors are announced immediately */}
        <div role="alert" aria-live="polite">
          {error}
        </div>
        <div className="field">
          <label htmlFor="email">Work Email</label>
          <input type="email" id="email" name="email" required />
        </div>
        <div className="actions">
          <button type="button" onClick={onBack}>
            Go Back
          </button>
          <button type="submit">Confirm & Next</button>
        </div>
      </form>
    </section>
  );
}

3. The Confirmation Layer (Review)

The final step renders a read-only summary of collected data using a <dl> (description list). This is semantically correct: a <dl> communicates key-value relationships to assistive tech, which a generic list or table would not. The role="status" region announces the confirmation without interrupting the user's current focus.

<section id="step-review" aria-labelledby="rev-title">
  <h2 id="rev-title">Step 3: Review Your Details</h2>

  <div role="status" aria-live="polite" id="confirm-status"></div>

  <dl class="review-list">
    <div class="review-item">
      <dt>Full Name</dt>
      <dd>Jane Doe</dd>
    </div>
    <div class="review-item">
      <dt>Work Email</dt>
      <dd>jane.doe@acme.com</dd>
    </div>
  </dl>

  <div class="actions">
    <button type="button" class="btn-alt" onclick="handleBack()">Go Back</button>
    <button type="button" class="btn-confirm" onclick="handleConfirm()">Confirm & Submit</button>
  </div>
</section>

<style>
  section {
    font-family: system-ui, sans-serif;
    max-width: 400px;
    padding: 1rem;
  }
  h2 {
    color: #1a1a2e;
    margin-bottom: 1rem;
  }
  #confirm-status:not(:empty) {
    background: #e6f4ea;
    border: 1px solid #34a853;
    color: #1a7337;
    padding: 0.75rem;
    border-radius: 4px;
    margin-bottom: 1rem;
  }
  .review-list {
    margin: 0 0 1.5rem;
    padding: 0;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    overflow: hidden;
  }
  .review-item {
    display: flex;
    justify-content: space-between;
    padding: 0.75rem 1rem;
    border-bottom: 1px solid #f0f0f0;
  }
  .review-item:last-child {
    border-bottom: none;
  }
  dt {
    font-weight: 600;
    color: #555;
    font-size: 0.875rem;
    text-transform: uppercase;
    letter-spacing: 0.03em;
  }
  dd {
    margin: 0;
    color: #222;
    font-size: 0.9rem;
  }
  .actions {
    display: flex;
    gap: 0.5rem;
    justify-content: space-between;
  }
  .btn-alt {
    background: #f0f0f0;
    color: #333;
    border: 1px solid #ccc;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }
  .btn-alt:hover {
    background: #e0e0e0;
  }
  .btn-confirm {
    background: #1a7337;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }
  .btn-confirm:hover {
    background: #145c2b;
  }
</style>

<script>
  function handleBack() {
    document.getElementById('confirm-status').textContent = '';
    alert('Going back to Security step...');
  }

  function handleConfirm() {
    const status = document.getElementById('confirm-status');
    status.textContent = '✓ Your details have been submitted successfully.';
    const btn = document.querySelector('.btn-confirm');
    if (btn) btn.disabled = true;
  }
</script>

Live Demo — Step 3: Review

Open in new tab ↗

The React version uses the aggregated formData passed down from the orchestrator:

// ReviewStep.tsx
interface FormData {
  name: string;
  email: string;
}

interface ReviewStepProps {
  data: FormData;
  onBack: () => void;
  onConfirm: () => void;
}

export function ReviewStep({ data, onBack, onConfirm }: ReviewStepProps) {
  const [submitted, setSubmitted] = useState(false);
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, []);

  function handleConfirm() {
    setSubmitted(true);
    onConfirm();
  }

  return (
    <section aria-labelledby="rev-title">
      <h2 id="rev-title" tabIndex={-1} ref={headingRef}>
        Step 3: Review Your Details
      </h2>

      {/* role="status" is polite — it waits for the user to finish what they're doing */}
      <div role="status" aria-live="polite">
        {submitted && '✓ Your details have been submitted successfully.'}
      </div>

      <dl>
        <div>
          <dt>Full Name</dt>
          <dd>{data.name}</dd>
        </div>
        <div>
          <dt>Work Email</dt>
          <dd>{data.email}</dd>
        </div>
      </dl>

      <div className="actions">
        <button type="button" onClick={onBack} disabled={submitted}>
          Go Back
        </button>
        <button type="button" onClick={handleConfirm} disabled={submitted}>
          Confirm & Submit
        </button>
      </div>
    </section>
  );
}

Project Structure

A clean repository separates the "How" (UI) from the "Where" (Navigation).

src/
├── core/
│   └── useFormState.ts    # The FSM Logic
├── steps/
│   ├── IdentityStep.tsx   # UI Only
│   ├── SecurityStep.tsx   # UI Only
│   └── ReviewStep.tsx     # UI Only
├── ui/
│   └── Fieldset.tsx       # Shared A11y Wrapper
└── App.tsx                # The Orchestrator

Logic Implementation: The Transition Engine

Keep your transitions mapped in a lookup table. It's faster and cleaner than nested switch statements.

type StepID = 'ID' | 'SEC' | 'REV';

const FLOW_MAP: Record<StepID, StepID | null> = {
  ID: 'SEC',
  SEC: 'REV',
  REV: null, // Terminal state — no further transition.
};

The lookup table is the skeleton. The useFormState hook is the muscle — it owns the current step, the accumulated data, and every legal transition. Nothing else in the application needs to know how transitions work.

// core/useFormState.ts
import { useState, useCallback } from 'react';

type StepID = 'ID' | 'SEC' | 'REV';

interface FormState {
  currentStep: StepID;
  formData: Record<string, unknown>;
}

interface UseFormStateReturn extends FormState {
  next: (stepData: Record<string, unknown>) => void;
  back: () => void;
  reset: () => void;
  isFirstStep: boolean;
  isLastStep: boolean;
}

const FLOW_MAP: Record<StepID, StepID | null> = {
  ID: 'SEC',
  SEC: 'REV',
  REV: null,
};

const BACK_MAP: Record<StepID, StepID | null> = {
  ID: null,
  SEC: 'ID',
  REV: 'SEC',
};

const INITIAL_STATE: FormState = {
  currentStep: 'ID',
  formData: {},
};

export function useFormState(): UseFormStateReturn {
  const [state, setState] = useState<FormState>(INITIAL_STATE);

  function focusCurrentHeading() {
    requestAnimationFrame(() => {
      (document.querySelector('h2') as HTMLElement | null)?.focus();
    });
  }

  const next = useCallback((stepData: Record<string, unknown>) => {
    setState(prev => {
      const nextStep = FLOW_MAP[prev.currentStep];
      if (!nextStep) return prev; // Already at terminal step.
      return {
        currentStep: nextStep,
        formData: { ...prev.formData, ...stepData },
      };
    });
    // Move focus to the new heading after React re-renders.
    focusCurrentHeading();
  }, []);

  const back = useCallback(() => {
    setState(prev => {
      const prevStep = BACK_MAP[prev.currentStep];
      if (!prevStep) return prev; // Already at the first step.
      return { ...prev, currentStep: prevStep };
    });
    focusCurrentHeading();
  }, []);

  const reset = useCallback(() => {
    setState(INITIAL_STATE);
  }, []);

  return {
    ...state,
    next,
    back,
    reset,
    isFirstStep: state.currentStep === 'ID',
    isLastStep: state.currentStep === 'REV',
  };
}

The isFirstStep and isLastStep flags give the orchestrator enough information to hide or disable navigation buttons without encoding any routing logic in the UI layer. The requestAnimationFrame call ensures focus is moved after React has committed the new step's DOM to the screen — attempting a querySelector synchronously would still return the old heading.

The Strategic Advantage

Why go through this effort? Because scale requires systems, not just code.

  1. Telemetry: You can log exactly where users drop off. If 40% of users quit at Step 2, your UX team has a clear target.
  2. Testing: You can unit test the "Security" validation logic without needing to render the "Identity" step.
  3. Accessibility Compliance: Small steps are easier to audit. By controlling focus on every transition, you ensure no user is left behind.

Stop building for your backend. Start building for the human being using your software.


Further Reading