Validating on Blur Versus on Input
Choosing whether a field reports its own validity when the user leaves it (blur) or on every keystroke (input) decides whether your form feels reassuring or nagging, and the right answer depends on whether the rule is cheap and local or leans on the Constraint Validation API versus a remote lookup. This page gives you a concrete rule for picking the event, a copy-paste baseline for each, and the failure modes that bite when you get the timing wrong.
When to Use This Event-Timing Approach
The distinction is about when the first error is allowed to appear and how often it re-evaluates after that. Use blur when the field cannot be judged mid-typing — an email is not “wrong”, it is merely “incomplete” until the user commits. Use input only after a field has already failed once, to clear the error the instant it becomes valid again.
- Validate on
blurfor the first verdict: emails, phone numbers, anything where partial input is meaningless. This is the pattern the Best Practices for Inline Validation Timing page calls “reward early, punish late”. - Validate on
inputto clear an existing error, or for live affordances like a password-strength meter where each character genuinely changes the verdict. - Debounce the
inputpath when the check is expensive; see Debouncing Real-Time Validation Input.
reportValidity() on Blur, checkValidity() on Input
The baseline every form on this site ships is <form novalidate> with validity driven through the native Constraint Validation API. Here blur triggers the visible message via reportValidity(), while input uses the silent checkValidity() to withdraw an error the moment the field passes — the “touched” flag is what prevents the input handler from shouting before the user has committed.
const form = document.querySelector<HTMLFormElement>("#signup")!;
// Track which fields the user has already left once.
const touched = new WeakSet<HTMLInputElement>();
for (const field of form.querySelectorAll<HTMLInputElement>("input")) {
// First verdict: only on blur, so partial input is never flagged.
field.addEventListener("blur", () => {
touched.add(field);
// reportValidity() runs the native constraints AND shows the message.
field.reportValidity();
});
// Subsequent re-checks: only for fields already touched, and silent.
field.addEventListener("input", () => {
if (!touched.has(field)) return; // don't nag before first blur
// checkValidity() re-runs constraints without popping UI...
if (field.checkValidity()) {
field.setCustomValidity(""); // ...so we can clear our own state
field.removeAttribute("aria-invalid");
}
});
}
// The single manual gate on submit — the canonical baseline.
form.addEventListener("submit", (event) => {
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity(); // focuses + messages the first invalid field
}
});
The form carries novalidate so the browser never fires its own bubbles; every message is under your control:
<form id="signup" novalidate>
<label>Email <input type="email" name="email" required></label>
<button type="submit">Create account</button>
</form>
Event & Method Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
blur listener |
EventListener |
none | Delivers the first, visible verdict when focus leaves the field |
input listener |
EventListener |
none | Re-evaluates a touched field on each keystroke to clear errors |
touched gate |
WeakSet<HTMLInputElement> |
empty | Suppresses input errors until the field has been blurred once |
reportValidity() |
() => boolean |
— | Runs constraints and shows the native message + focus |
checkValidity() |
() => boolean |
— | Runs constraints silently; returns validity without UI |
setCustomValidity(msg) |
(msg: string) => void |
"" |
Sets/clears a custom validity message; empty string means valid |
aria-invalid |
attribute | absent | Signals invalid state to assistive tech; pair with aria-describedby |
Verification Steps
import { test, expect } from "@playwright/test";
test("blur shows the first error, input clears it", async ({ page }) => {
await page.goto("/signup");
const email = page.getByLabel("Email");
await email.fill("not-an-email");
await email.blur();
// Native message surfaced only after blur.
expect(await email.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(false);
await email.fill("dev@example.com");
// input path re-checks and the field is valid without a second blur.
expect(await email.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true);
});
Edge Cases & Failure Modes
Blur that never fires on submit. Clicking the submit button before leaving the last field means that field was never blurred, so its input-gated handler stays silent — the submit-time form.reportValidity() is your safety net and must remain. This ties directly into focus management after a failed submit.
Live input validation on remote checks. Wiring asynchronous server checks — like “is this username taken?” — to input fires a request per keystroke. Move those to blur or debounce them; the input path should stay synchronous and cheap.
Programmatic value changes skip both events. Autofill and field.value = "..." set from code do not always dispatch input or blur, so a stale error can linger. Dispatch a synthetic new Event("input", { bubbles: true }) after any scripted mutation, or re-run checkValidity() when integrating a Zod schema validation layer that owns the field state.
focusout Versus blur for Event Delegation
The per-field loop above attaches a blur listener to every input, which is fine for a handful of fields but wasteful for a large form and outright broken for fields added after page load. The instinct is to delegate — one listener on the <form> — but blur does not bubble, so a listener on the form never sees a child’s blur. The DOM provides a bubbling counterpart precisely for this: focusout. It fires for the same focus-loss moment as blur, but it propagates up the tree, so a single delegated handler catches every field including ones injected later.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const touched = new WeakSet<HTMLInputElement>();
// One delegated listener, because focusout bubbles and blur does not.
form.addEventListener("focusout", (event) => {
const field = event.target;
// focusout also fires for the <form> and non-field elements — narrow first.
if (!(field instanceof HTMLInputElement)) return;
touched.add(field);
field.reportValidity();
});
// The input event already bubbles, so its delegation needs no special event.
form.addEventListener("input", (event) => {
const field = event.target;
if (!(field instanceof HTMLInputElement)) return;
if (!touched.has(field)) return;
if (field.checkValidity()) {
field.setCustomValidity("");
field.removeAttribute("aria-invalid");
}
});
The instanceof HTMLInputElement guard is not defensive padding — event.target on a delegated focusout is typed as EventTarget | null, and the check both narrows the type for the compiler and skips focus events bubbling from wrapper elements or a <button>. If you also validate <select> and <textarea> fields, widen the guard to HTMLElement and feature-test for checkValidity rather than listing every element type.
Choosing change Over input for Discrete Controls
input and blur are the right pair for free-text fields, but they are the wrong instrument for controls whose value changes in discrete jumps: checkboxes, radio groups, <select> menus, and <input type="color"> or type="range">. A checkbox never emits keystroke-level input events the way a text box does, and there is no meaningful “partial” state to protect the user from — the value is valid or not the moment they act on it. For these, the change event is both the first verdict and the re-check, collapsing the two-phase blur/input dance into one.
// A required terms-of-service checkbox and a required country <select>.
for (const control of form.querySelectorAll<HTMLInputElement | HTMLSelectElement>(
"input[type=checkbox], select",
)) {
control.addEventListener("change", () => {
// change fires only on a committed choice, so no touched-gate is needed.
const ok = control.checkValidity();
control.toggleAttribute("aria-invalid", !ok);
if (!ok) control.reportValidity();
});
}
The distinction matters for consistency: if a text email field only complains on blur but the country selector complains on the same change that also sets a valid value, the form feels coherent because both wait for a committed action rather than an intermediate state. Mixing input into a <select> handler gains you nothing — the event fires at the identical moment as change for that control — while input on a checkbox is simply not dispatched by every browser, so change is the portable choice. Reserve the blur-then-input pattern for the free-text fields where an incomplete value genuinely needs shielding, and let discrete controls report on change.
Frequently Asked Questions
Should I validate on blur or on input for an email field?
Use blur for the first verdict, because an email is incomplete rather than wrong while the user is still typing. Once an error is showing, switch that field to input so the message clears the instant the address becomes valid.
Why does my error appear on the first keystroke?
Your input handler is running before the field has ever been blurred. Gate it behind a "touched" set so the handler bails out with an early return until the field has lost focus at least once.
Does blur fire if the user submits without leaving the last field?
Not reliably — clicking submit can bypass the last field's blur. That is exactly why the submit handler still calls form.reportValidity(), which validates and focuses the first invalid field regardless of what was blurred.
Why can't I catch blur with a single listener on the form?
Because blur does not bubble, so it never reaches a delegated handler on the parent form. Listen for focusout instead — it fires at the same focus-loss moment but propagates up the tree, letting one handler cover every field, including those added dynamically.
Should checkboxes and selects validate on blur or input?
Neither — use the change event. Discrete controls have no partial state to shield the user from, so the moment they pick a value is both the first verdict and the re-check. input is not even dispatched reliably for checkboxes across browsers, which makes change the portable choice.
Related Guides
- Best Practices for Inline Validation Timing — the full “reward early, punish late” model this recipe implements.
- Debouncing Real-Time Validation Input — when to slow the
inputpath for expensive checks. - Managing Focus After Validation Failure — where the submit-time safety net sends focus.
- Inline Error Messaging Strategies — writing the messages these events reveal accessibly.