Skip to main content

๐Ÿšฆ Error Handling and Feedback

Every form eventually meets a mistake โ€” a mistyped email, a skipped required field, a mismatched password. How your form responds in that moment decides whether the user recovers calmly or gives up. This lesson turns errors into helpful guidance.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Prevent errors up front with input types, constraints, and smart defaults
  • Choose the right validation timing โ€” on input, on blur, and on submit
  • Use the browser's Constraint Validation API for field, cross-field, and custom rules
  • Write clear, accessible error messages and a focusable error summary
  • Preserve user data and confirm success so the whole flow feels reliable

Estimated Time: 40โ€“50 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a validated signup with live feedback and an error summary.

In This Lesson

Errors Are a Design Problem

When people hit an error, their mood can flip from confident to frustrated in seconds. Good error handling catches that fall: it explains what went wrong, points to the fix, and never loses their work. Bad error handling โ€” vague messages, wiped fields, no guidance โ€” is one of the leading causes of form abandonment.

๐Ÿ’ก A useful analogy: Error handling is a friendly GPS. It doesn't shout "WRONG!" when you miss a turn โ€” it calmly says "in 200 metres, make a U-turn." Your form should do the same: acknowledge the wrong turn and show the way back.

Think of error handling as four cooperating stages. Strong forms invest in all four, not just detection:

flowchart TD A[Error handling] --> B[Prevention] A --> C[Detection] A --> D[Communication] A --> E[Recovery] B --> B1[Clear instructions
Input constraints
Smart defaults] C --> C1[Field validation
Cross-field checks] D --> D1[Specific messages
Accessible alerts] E --> E1[Preserve data
Suggest fixes]

๐Ÿ“– Key Terms

Client-side validation: checks in the browser for fast feedback. Convenient but never trustworthy on its own.

Server-side validation: checks on the server. The real security boundary โ€” always required.

Constraint Validation API: the browser's built-in JavaScript interface for validating form fields.

โš ๏ธ Client-side validation is UX, not security

Everything in this lesson improves the experience, but a determined user can bypass browser checks entirely. Always re-validate on the server. Treat client-side validation as a fast, friendly first line โ€” never the last one.

Prevention: The Best Error

The error message a user never sees is the best one. Much invalid input can be designed out before it happens.

Pick the right input type & constraints

Native types and attributes constrain input for free, giving mobile keyboards, built-in validation, and clear expectations:

<!-- Number with a valid range -->
<label for="age">Age</label>
<input type="number" id="age" name="age" min="18" max="120" step="1">

<!-- Date bounded to a booking window -->
<label for="date">Appointment date</label>
<input type="date" id="date" name="date" min="2026-01-01" max="2026-12-31">

<!-- Pattern for a US ZIP (5 or 5+4) -->
<label for="zip">ZIP code</label>
<input type="text" id="zip" name="zip"
       pattern="[0-9]{5}(-[0-9]{4})?" inputmode="numeric"
       placeholder="12345 or 12345-6789">

<!-- A constrained choice can't be mistyped -->
<label for="state">State</label>
<select id="state" name="state" required>
  <option value="">Choose a state</option>
  <option value="AL">Alabama</option>
  <option value="AK">Alaska</option>
</select>

Guide before they type

State requirements up front so users get them right the first time. A password checklist is far kinder than rejecting a submission after the fact.

<div class="form-group">
  <label for="password">Create password</label>
  <p id="password-help" class="form-hint">
    Use at least 8 characters, one number, and one symbol.
  </p>
  <input type="password" id="password" name="password"
         minlength="8" required aria-describedby="password-help password-error">
  <p id="password-error" class="error-message" role="alert" hidden></p>
</div>

โœ… Prevention checklist

  • Right input type for every field (email, tel, number, date).
  • min, max, minlength, maxlength, pattern to bound values.
  • Selects/radios instead of free text for fixed choices.
  • Sensible, carefully chosen defaults.

When to Validate

The moment you show an error matters as much as its wording. Validate too early and you scold users mid-typing; too late and they face a wall of red at submit.

graph TD A[Validation timing] --> B[On input] A --> C[On blur] A --> D[On submit] B --> B1[Immediate
Great for strength meters
Can nag] C --> C1[When leaving a field
Good default balance] D --> D1[Final safety net
Always required]

The widely recommended pattern is a hybrid:

  • On input for live indicators like password strength or character count.
  • On blur for most fields โ€” validate a field once the user has clearly finished it, and clear an error the moment they correct it.
  • On submit always, as the final gate before sending data.
A kind rule of thumb: validate late, forgive early. Wait until a user leaves a field before flagging a problem, but remove the error the instant they start fixing it. Being quick to complain and slow to forgive feels hostile.
const form = document.getElementById('signup');

// On blur: validate the field the user just left (skip empty optional)
form.addEventListener('blur', (event) => {
  const field = event.target;
  if (!field.matches('input, select, textarea')) return;
  if (!field.required && !field.value) return;
  validateField(field);
}, true); // capture phase so blur reaches the handler

// On submit: validate everything, then focus the first problem
form.addEventListener('submit', (event) => {
  const fields = form.querySelectorAll('input, select, textarea');
  let firstInvalid = null;
  for (const field of fields) {
    if (!validateField(field) && !firstInvalid) firstInvalid = field;
  }
  if (firstInvalid) {
    event.preventDefault();
    firstInvalid.focus();
  }
});

The Constraint Validation API

Modern browsers expose a rich JavaScript API for validation, so you rarely need to write regexes from scratch. Every form control carries a validity object and a setCustomValidity() method.

Reading the validity state

The ValidityState object tells you exactly why a field is invalid, which lets you tailor the message:

function validateField(field) {
  const errorEl = document.getElementById(`${field.id}-error`);
  if (!errorEl) return field.checkValidity();

  // Clear any previous custom message before re-checking
  field.setCustomValidity('');

  let message = '';
  const v = field.validity;

  if (v.valueMissing) {
    message = 'This field is required.';
  } else if (v.typeMismatch && field.type === 'email') {
    message = 'Enter a valid email, like name@example.com.';
  } else if (v.tooShort) {
    message = `Use at least ${field.minLength} characters.`;
  } else if (v.rangeUnderflow || v.rangeOverflow) {
    message = `Enter a value between ${field.min} and ${field.max}.`;
  } else if (v.patternMismatch) {
    message = 'Check the format and try again.';
  }

  const valid = message === '';
  field.setAttribute('aria-invalid', String(!valid));
  errorEl.textContent = message;
  errorEl.hidden = valid;
  return valid;
}

Cross-field validation

Some rules span two fields โ€” password confirmation is the classic case. Compare the values and set a custom message on the dependent field:

function validatePasswordMatch() {
  const password = document.getElementById('password');
  const confirm = document.getElementById('confirm-password');
  const errorEl = document.getElementById('confirm-password-error');

  const mismatch = confirm.value !== '' && confirm.value !== password.value;
  confirm.setAttribute('aria-invalid', String(mismatch));
  errorEl.textContent = mismatch ? 'Passwords do not match.' : '';
  errorEl.hidden = !mismatch;
  return !mismatch;
}

Custom & async rules

For rules HTML can't express โ€” "username already taken" โ€” combine setCustomValidity() with an async check. Remember to re-report validity once the async result returns.

const username = document.getElementById('username');

username.addEventListener('input', async () => {
  username.setCustomValidity(''); // reset first

  if (/\s/.test(username.value)) {
    username.setCustomValidity('Username cannot contain spaces.');
    return;
  }
  if (username.value.length < 3) {
    username.setCustomValidity('Username must be at least 3 characters.');
    return;
  }

  const available = await isUsernameAvailable(username.value);
  if (!available) {
    username.setCustomValidity('That username is already taken.');
    username.reportValidity(); // surface the async result
  }
});

async function isUsernameAvailable(name) {
  const res = await fetch(`/api/username-available?name=${encodeURIComponent(name)}`);
  const { available } = await res.json();
  return available;
}

Designing Error Messages

A good error message answers two questions instantly: what's wrong? and how do I fix it? It does so in plain, non-blaming language.

graph TD A[Weak messages] --> A1[Generic: 'Invalid input'] A --> A2[Technical: 'Error E4002'] A --> A3[Blaming: 'You did it wrong'] B[Strong messages] --> B1[Specific: 'Email is missing an @'] B --> B2[Instructive: 'Enter a date as MM/DD/YYYY'] B --> B3[Helpful: 'That name is taken โ€” try adding a number']

Present errors clearly

Place the message next to its field, and signal state with more than color โ€” an icon plus text so color-blind users aren't excluded. Use CSS variables so the styling adapts to light and dark themes automatically.

.form-control[aria-invalid="true"] {
  border-color: var(--warning-border);
}
.error-message {
  margin-top: 0.25rem;
  font-size: 0.9rem;
  color: var(--warning-border);
}
.error-message::before {
  content: "โš ๏ธ ";
}
.feedback-message.success {
  color: var(--success-border);
}

Add an error summary for longer forms

When several fields fail at once, a summary at the top gives an at-a-glance list with a link to each problem. Make it role="alert" and focusable so screen-reader and keyboard users land on it immediately.

<div id="error-summary" class="error-summary" role="alert" tabindex="-1" hidden>
  <h2>There is a problem</h2>
  <ul id="error-list"></ul>
</div>
function showErrorSummary(errors) {
  const summary = document.getElementById('error-summary');
  const list = document.getElementById('error-list');
  list.innerHTML = '';

  for (const error of errors) {
    const li = document.createElement('li');
    const link = document.createElement('a');
    link.href = `#${error.id}`;
    link.textContent = error.message;
    link.addEventListener('click', (e) => {
      e.preventDefault();
      document.getElementById(error.id).focus();
    });
    li.appendChild(link);
    list.appendChild(li);
  }

  summary.hidden = false;
  summary.focus(); // tabindex="-1" makes this possible
}
An error summary linking to two invalid fields A box at the top of a form titled "There is a problem" lists two errors as links. Arrows point down to the email and password fields those links jump to. โš ๏ธ There is a problem โ€ข Enter a valid email address โ€ข Password must be at least 8 characters Email Password
Figure 1 โ€” An error summary lists every problem and links straight to the field, so users fix issues in one focused pass.

Recovery & Data Preservation

Never lose their work

The fastest way to make someone abandon a form is to wipe it after one mistake. Keep every value on a failed submit, and for longer forms, back up progress to sessionStorage or localStorage so an accidental refresh isn't catastrophic.

const form = document.getElementById('signup');
const STORAGE_KEY = 'signup-draft';

// Save a draft as the user types (skip passwords!)
form.addEventListener('input', () => {
  const draft = {};
  for (const field of form.elements) {
    if (!field.name || field.type === 'password') continue;
    draft[field.name] = field.type === 'checkbox' ? field.checked : field.value;
  }
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
});

// Restore on load
window.addEventListener('DOMContentLoaded', () => {
  const saved = sessionStorage.getItem(STORAGE_KEY);
  if (!saved) return;
  const draft = JSON.parse(saved);
  for (const [name, value] of Object.entries(draft)) {
    const field = form.elements[name];
    if (!field) continue;
    if (field.type === 'checkbox') field.checked = value;
    else field.value = value;
  }
});

// Clear the draft once the form succeeds
form.addEventListener('submit', () => {
  if (form.checkValidity()) sessionStorage.removeItem(STORAGE_KEY);
});

โš ๏ธ Don't persist secrets

Never write passwords, card numbers, or other sensitive data to localStorage/sessionStorage โ€” it's readable by any script on the page and survives after the user leaves. Skip those fields when saving drafts, as the code above does.

Suggest the likely fix

When you can guess the intended value, offer it. "Did you mean name@gmail.com?" turns a dead end into a one-click correction. A small typo table catches the most common email domain mistakes:

function suggestEmail(email) {
  const [user, domain] = email.split('@');
  if (!domain) return null;

  const typos = { 'gmial.com': 'gmail.com', 'gmai.com': 'gmail.com',
                  'yaho.com': 'yahoo.com', 'hotmial.com': 'hotmail.com',
                  'outlok.com': 'outlook.com' };

  const fixed = typos[domain.toLowerCase()];
  return fixed ? `${user}@${fixed}` : null;
}

// Usage: offer the suggestion as a clickable link
const email = document.getElementById('email');
email.addEventListener('blur', () => {
  const guess = suggestEmail(email.value);
  const errorEl = document.getElementById('email-error');
  if (guess) {
    errorEl.innerHTML = `Did you mean <button type="button" class="link">${guess}</button>?`;
    errorEl.hidden = false;
    errorEl.querySelector('button').addEventListener('click', () => {
      email.value = guess;
      errorEl.hidden = true;
    });
  }
});

Confirming Success

Feedback isn't only for failure. During submission, keep users informed; after it, confirm clearly. Silence after a click makes people wonder if it worked โ€” and click again.

Loading state & double-submit protection

Disable the button and show progress while the request is in flight, then restore state in a finally block so a failure doesn't leave the button stuck.

const form = document.getElementById('signup');
const button = form.querySelector('button[type="submit"]');
const feedback = document.getElementById('feedback');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  button.disabled = true;
  button.textContent = 'Creating accountโ€ฆ';

  try {
    const res = await fetch('/api/signup', {
      method: 'POST',
      body: new FormData(form)
    });

    if (res.ok) {
      feedback.className = 'feedback-message success';
      feedback.textContent = 'Account created! Check your email to confirm.';
    } else {
      const { message } = await res.json();
      feedback.className = 'feedback-message error';
      feedback.textContent = message || 'Something went wrong. Please try again.';
    }
  } catch (err) {
    feedback.className = 'feedback-message error';
    feedback.textContent = 'Connection problem. Check your network and retry.';
  } finally {
    button.disabled = false;
    button.textContent = 'Create account';
  }
});

Confirm what happened next

A good confirmation says what succeeded, gives any reference number, and points to the next step. It reassures the user their effort landed.

โœ… A strong confirmation includes

  • A clear, prominent success message ("Registration complete!").
  • Confirmation of key details (where the email was sent).
  • A reference number when relevant.
  • An obvious next action ("Go to your dashboard").

Hands-on Exercise

๐Ÿ‹๏ธ Build a Validated Signup

Objective: Create a small signup form with hybrid validation, accessible error messages, and an error summary.

Requirements:

  1. Fields: email (required), password (required, min 8), confirm password (must match).
  2. Validate each field on blur and everything on submit.
  3. Each field has an id'd role="alert" error element and uses aria-invalid.
  4. On a failed submit, show a focusable error summary linking to each problem.
  5. Use the Constraint Validation API (validity) rather than hand-rolled regex where possible.
๐Ÿ’ก Hint

Reuse the validateField() function from the API section for email and password, and validatePasswordMatch() for the confirm field. Your submit handler collects failures into an array of { id, message } objects, then calls showErrorSummary(). Add novalidate to the <form> so the browser's default bubbles don't compete with yours.

โœ… Sample solution
<form id="signup" novalidate>
  <div id="error-summary" class="error-summary" role="alert" tabindex="-1" hidden>
    <h2>There is a problem</h2>
    <ul id="error-list"></ul>
  </div>

  <div class="form-group">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required
           autocomplete="email" aria-describedby="email-error">
    <p id="email-error" class="error-message" role="alert" hidden></p>
  </div>

  <div class="form-group">
    <label for="password">Password</label>
    <input type="password" id="password" name="password" required minlength="8"
           autocomplete="new-password" aria-describedby="password-error">
    <p id="password-error" class="error-message" role="alert" hidden></p>
  </div>

  <div class="form-group">
    <label for="confirm-password">Confirm password</label>
    <input type="password" id="confirm-password" name="confirm" required
           autocomplete="new-password" aria-describedby="confirm-password-error">
    <p id="confirm-password-error" class="error-message" role="alert" hidden></p>
  </div>

  <button type="submit">Create account</button>
</form>

<script>
  const form = document.getElementById('signup');

  function validateField(field) {
    const errorEl = document.getElementById(`${field.id}-error`);
    let message = '';
    const v = field.validity;
    if (v.valueMissing) message = 'This field is required.';
    else if (v.typeMismatch) message = 'Enter a valid email address.';
    else if (v.tooShort) message = `Use at least ${field.minLength} characters.`;
    const valid = message === '';
    field.setAttribute('aria-invalid', String(!valid));
    errorEl.textContent = message;
    errorEl.hidden = valid;
    return valid;
  }

  function validateMatch() {
    const pw = document.getElementById('password');
    const cf = document.getElementById('confirm-password');
    const errorEl = document.getElementById('confirm-password-error');
    const bad = cf.value !== '' && cf.value !== pw.value;
    cf.setAttribute('aria-invalid', String(bad));
    errorEl.textContent = bad ? 'Passwords do not match.' : '';
    errorEl.hidden = !bad;
    return !bad;
  }

  form.addEventListener('blur', (e) => {
    if (!e.target.matches('input')) return;
    validateField(e.target);
    if (e.target.id === 'confirm-password') validateMatch();
  }, true);

  form.addEventListener('submit', (e) => {
    const errors = [];
    ['email', 'password'].forEach((id) => {
      const field = document.getElementById(id);
      if (!validateField(field)) {
        errors.push({ id, message: document.getElementById(`${id}-error`).textContent });
      }
    });
    if (!validateMatch()) {
      errors.push({ id: 'confirm-password', message: 'Passwords do not match.' });
    }
    if (errors.length) {
      e.preventDefault();
      const summary = document.getElementById('error-summary');
      const list = document.getElementById('error-list');
      list.innerHTML = '';
      errors.forEach((err) => {
        const li = document.createElement('li');
        const a = document.createElement('a');
        a.href = `#${err.id}`;
        a.textContent = err.message;
        a.addEventListener('click', (ev) => {
          ev.preventDefault();
          document.getElementById(err.id).focus();
        });
        li.appendChild(a);
        list.appendChild(li);
      });
      summary.hidden = false;
      summary.focus();
    }
  });
</script>

๐ŸŽฏ Quick Quiz

Question 1: Why must you always validate form data on the server, even with client-side validation?

Question 2: Which validation-timing strategy is generally recommended for the best experience?

Question 3: What makes an error summary accessible to screen-reader and keyboard users?

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • Prevent first: right input types, constraints, and clear up-front guidance stop most errors.
  • Validate late, forgive early โ€” on blur for most fields, always on submit; clear errors as they're fixed.
  • The Constraint Validation API (validity, setCustomValidity) handles field, cross-field, and async rules.
  • Messages should be specific, kind, and paired with icons โ€” never color alone โ€” plus a focusable error summary.
  • Never lose data; suggest fixes, and confirm success clearly. And always re-validate on the server.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You've mastered the form lifecycle end to end. Next we leave forms behind and explore browser storage โ€” Local Storage and Session Storage โ€” the APIs that let you persist data (like the form drafts you saw here) right in the browser.

๐ŸŽ‰ Module 4 mastered!

Accessible, usable, resilient forms are a genuinely valuable skill. Well done โ€” on to browser storage.