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.


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>
<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>

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>
<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>

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> = {
  ID: 'SEC',
  SEC: 'REV',
  REV: 'REV'
};

const handleNext = (current: StepID, data: any) => {
  const nextStep = FLOW_MAP[current];
  saveToState(data);
  navigateTo(nextStep);
  // Critical: Move focus to the new heading
  document.querySelector('h2')?.focus();
};

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.