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 blur for 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 input to clear an existing error, or for live affordances like a password-strength meter where each character genuinely changes the verdict.
  • Debounce the input path when the check is expensive; see Debouncing Real-Time Validation Input.
Blur versus input timing Two columns comparing validation triggered on the blur event against validation triggered on every input event. on blur (field commit) - fires once, when focus leaves - no mid-typing false errors - ideal first verdict best when: input is incomplete until done on input (per keystroke) - fires on every character - clears errors instantly - can nag if used first best when: clearing a shown error
Blur delivers the first verdict without nagging; input is best reserved for clearing an error the user is actively fixing.

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.

← Back to Real-Time vs On-Submit Feedback Timing