Listening for the invalid Event

Every time checkValidity() or reportValidity() finds a failing control, the browser fires an invalid event on that control. It is the hook the platform gives you for rendering your own error messages — and it is widely misunderstood. It does not bubble, so a listener on the form never hears it unless you listen in the capture phase. Cancelling it suppresses the browser’s own bubble but not the focus move. It fires once per failing control, in document order, which makes it the natural place to build an error summary. This recipe uses the invalid event with the site’s canonical <form novalidate> plus manual reportValidity() pattern from the Constraint Validation API to render inline messages, collect a summary, and keep focus behaviour predictable.

When to Use the invalid Event

The invalid event is the right tool when you want the browser to decide what is invalid and where focus goes, but you want to control how errors look. Use it when:

  • You render inline messages from validationMessage or your own catalogue, in place of the browser’s bubble.
  • You build an error summary and need every failing control, in order, from one validation pass.
  • You want one code path for native constraints and custom ones set with setCustomValidity(), since both fire invalid.

If you validate entirely in script — reading each field’s validity in a loop — you do not need the event; the loop gives you the same information. The event shines precisely because it lets checkValidity() do the walking, which is why it pairs so naturally with the canonical flow described in checkValidity vs reportValidity differences.

How invalid events fire during reportValidity The submit handler calls reportValidity; the browser checks each control in order, fires invalid on each failing control, the capture listener renders messages, and the browser focuses the first failing control. Submit handler Browser Capture listener First invalid field form.reportValidity() invalid on email (1st) invalid on postcode (2nd) preventDefault(), render inline messages focus() returns false
One validation pass fires invalid on every failing control in document order, then focuses the first one; cancelling the event only suppresses the native bubble.

Minimal Working invalid-Event Handler

const form = document.querySelector<HTMLFormElement>("#signup")!;
const summary = form.querySelector<HTMLElement>("#error-summary")!;
const summaryList = summary.querySelector("ul")!;
let failures: Array<{ id: string; message: string }> = [];

// The invalid event does NOT bubble; listen in the capture phase to delegate from the form.
form.addEventListener(
  "invalid",
  (event) => {
    const field = event.target as HTMLInputElement;
    event.preventDefault();                                  // suppress the native bubble only
    const message = messageFor(field);
    failures.push({ id: field.id, message });
    const out = document.getElementById(`${field.id}-err`);
    if (out) { out.textContent = message; out.hidden = false; }
    field.setAttribute("aria-invalid", "true");
  },
  true,                                                      // capture
);

function messageFor(field: HTMLInputElement): string {
  const v = field.validity;
  const label = form.querySelector(`label[for="${field.id}"]`)?.textContent?.trim() ?? "This field";
  if (v.customError) return field.validationMessage;         // your own rules win
  if (v.valueMissing) return `Enter your ${label.toLowerCase()}.`;
  if (v.typeMismatch && field.type === "email") return "Enter an email address like name@example.com.";
  if (v.tooShort) return `${label} must be at least ${field.minLength} characters.`;
  return field.validationMessage;                            // native fallback
}

function clearErrors(): void {
  failures = [];
  for (const out of form.querySelectorAll<HTMLElement>(".field-error")) { out.hidden = true; out.textContent = ""; }
  for (const el of form.querySelectorAll("[aria-invalid]")) el.removeAttribute("aria-invalid");
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  clearErrors();
  const ok = form.reportValidity();                          // fires invalid on each failure, focuses the first
  if (!ok) {
    summaryList.replaceChildren(...failures.map((f) => {
      const li = document.createElement("li");
      li.append(Object.assign(document.createElement("a"), { href: `#${f.id}`, textContent: f.message }));
      return li;
    }));
    summary.hidden = false;
    return;
  }
  summary.hidden = true;
  form.submit();
});

// Clear a field's message as soon as it becomes valid again.
form.addEventListener("input", (event) => {
  const field = event.target as HTMLInputElement;
  if (field.getAttribute("aria-invalid") === "true" && field.checkValidity()) {
    field.removeAttribute("aria-invalid");
    const out = document.getElementById(`${field.id}-err`);
    if (out) out.hidden = true;
  }
});

Note the checkValidity() inside the input listener: it too fires invalid if the field is still failing, which would re-render the message on every keystroke. That is harmless here because the handler only writes the same message again, but if your invalid handler has side effects — analytics, announcements — guard it with a flag or use field.validity.valid in the input handler instead, which does not fire events.

One validation pass, three outputs A single reportValidity call produces invalid events that feed inline messages and the error summary, while the browser moves focus to the first failing field. reportValidity() one pass over controls invalid events one per failing control Inline messages #id-err containers Error summary links in document order Focus first failing control
The browser walks the controls once; the invalid listener turns that walk into inline messages and a summary without a second loop.

invalid Event Reference

Property Value Meaning
Fired by checkValidity(), reportValidity(), native submit without novalidate Once per failing control
bubbles false Use a capture listener on the form to delegate
cancelable true preventDefault() suppresses the browser’s message bubble
Target the failing control Read validity and validationMessage from it
Order document order Matches the order focus and summaries should follow
Focus not affected by cancelling reportValidity() still focuses the first failure

The difference between checkValidity() and reportValidity() matters here: both fire invalid, but only reportValidity() moves focus and, if not cancelled, shows the native bubble. checkValidity() is therefore a silent way to populate your UI — useful for a “check my answers” button that should not steal focus.

Verification Steps

import { test, expect } from "@playwright/test";

test("invalid events render inline messages and a summary", async ({ page }) => {
  await page.goto("/signup");
  await page.getByRole("button", { name: "Create account" }).click();
  const summary = page.locator("#error-summary li");
  await expect(summary.first()).toHaveText("Enter your email address.");
  await expect(page.getByLabel("Email address")).toBeFocused();
  await expect(page.locator("#email-err")).toHaveText("Enter your email address.");
});

Edge Cases and Failure Modes

Bubbling listeners never fire. form.addEventListener("invalid", handler) without the capture flag hears nothing from child controls. This is the most common reason “my invalid handler doesn’t work”.

Stale failures between passes. The failures array must be reset before each validation pass, or the summary accumulates duplicates from earlier submits — the clearErrors() call does this.

Custom elements. Form-associated custom elements fire invalid too, targeted at the host element, so the same listener covers them; read the message from the host’s validationMessage, as described in ElementInternals setValidity for custom inputs.

Radio groups. Each radio in a required, empty group is individually invalid, so invalid fires for every radio in the group. De-duplicate by name when collecting failures, or the summary lists the same group several times — the grouping pattern is in validating radio groups and fieldsets.

Using the invalid Event Without novalidate

Without novalidate, the browser validates on submit by itself and fires invalid on each failing control before blocking submission. A capture listener that calls preventDefault() then suppresses the native bubbles while still letting the browser block submission and focus the first failure — a way to style native validation without writing a submit handler at all. It is tempting, but it gives up control of timing (validation only on submit), and in some browsers the focus and scroll behaviour differ from reportValidity(). The site’s baseline keeps novalidate and calls reportValidity() explicitly, so the invalid listener behaves identically everywhere and the submit handler can await asynchronous rules before validating, as described in prevent default form submission without losing validation.

Analytics From invalid Events

Because the event fires once per failing control with the full validity object attached, it is the cleanest place to measure which rules users actually trip. Record the field name and the failing flag — valueMissing, typeMismatch, customError with your message key — but never the value. Aggregated over time, those counts show which fields cause the most friction and which rules might be too strict, the same fairness signal recommended in validating common input types. Guard against double counting: only record events from submit-time passes, not from the checkValidity() calls your live input handlers make.

Moving Focus to the Summary Instead

Focusing the first failing field is right when there is one error. With several, some teams prefer to move focus to the error summary, so screen reader users hear “There are 3 problems” and the list before fixing anything. The invalid event does not stop reportValidity() from focusing the first field, so to change the target, use checkValidity() (which does not move focus) and focus the summary yourself. Both choices are defensible; what matters is consistency across the product, and that the summary’s links move focus to each field. The trade-offs are discussed in error summary vs inline errors and the component itself in building an accessible error summary.

const ok = form.checkValidity();                   // fires invalid, does NOT move focus
if (!ok) {
  renderSummary(failures);
  (failures.length > 1 ? summary : document.getElementById(failures[0].id))?.focus();
}
Where should focus go after a failed submit? A decision tree choosing between reportValidity focusing the first invalid field and checkValidity plus focusing the error summary, depending on the number of errors. How many fields failed? one reportValidity → focus that field several Does the form show a summary? yes checkValidity → focus the summary no reportValidity → focus the first field
One error: let reportValidity focus the field. Several: use checkValidity and focus the summary so the user hears the scope first.

Frequently Asked Questions

Why doesn't my form-level invalid event listener fire?

The invalid event does not bubble. Register the listener on the form with the capture flag, addEventListener("invalid", handler, true), so it receives events from every control inside.

Does preventDefault on the invalid event stop focus from moving?

No. It only suppresses the browser's own validation bubble. reportValidity still focuses the first invalid control; use checkValidity if you want to control focus yourself.

When does the invalid event fire?

When checkValidity or reportValidity finds a failing control, and during native form submission without novalidate. It fires once per failing control, in document order.

Do custom setCustomValidity errors fire the invalid event?

Yes. A control with a custom validity message is invalid, so it fires invalid like any native constraint failure, and validity.customError is true.

← Back to Constraint Validation API Deep Dive