๐ฆ 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:
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
typefor every field (email, tel, number, date). min,max,minlength,maxlength,patternto 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.
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.
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
}
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:
- Fields: email (required), password (required, min 8), confirm password (must match).
- Validate each field on blur and everything on submit.
- Each field has an
id'drole="alert"error element and usesaria-invalid. - On a failed submit, show a focusable error summary linking to each problem.
- 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
- MDN โ Client-side form validation
- Nielsen Norman Group โ Error Message Guidelines
- W3C WAI โ User Notifications
- U.S. Web Design System โ Validation
๐ 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.