Clearing a Custom Validity Message on the Next Input Event

Once you push a message through setCustomValidity(), that field stays permanently invalid until you explicitly clear it, so this page shows the exact input-event pattern that wipes the message the instant the user starts typing a correction, keeping the Constraint Validation API in sync with what the user is actually doing.

When to Use This Input-Event Reset Approach

A custom validity string is sticky by design. The browser will not remove it on its own, will keep the control’s :invalid pseudo-class active, and will keep blocking submission until you call setCustomValidity(""). The most predictable moment to clear it is the first input event after the message was set, because that event fires exactly when the user changes the value in response to the error.

Reach for this reset when:

  • You set the message yourself with setCustomValidity after a submit or blur, and you want it gone the moment the user reacts.
  • You are surfacing the result of asynchronous server checks (a taken username, for example) and the stale message should not survive an edit.
  • You want native re-validation to take over again, rather than freezing the field on a message that no longer matches its contents.

Prefer a different tactic when the error is derived from another field’s value: that belongs to cross-field validation, where clearing on a single field’s input event can hide a still-broken relationship.

Clearing a custom validity message on input A custom message is set, the input event fires as the user types, and setCustomValidity is reset to empty. Lifecycle of a sticky custom validity string Message set setCustomValidity("…") User types input event fires Message cleared setCustomValidity("")
The input event is the reliable hook that turns a permanent custom message back into native validation.

Clearing With setCustomValidity(“”) on the input Event

The baseline below keeps the site convention: the form ships novalidate, and validity is driven through a single manual reportValidity() call. Each control gets one input listener whose only job is to clear a custom message that may have been set elsewhere.

const form = document.querySelector<HTMLFormElement>("#signup")!;
const email = form.elements.namedItem("email") as HTMLInputElement;

// Clear any custom message the moment the user edits this field.
// An empty string is the ONLY value that marks the control valid again.
email.addEventListener("input", () => {
  if (email.validationMessage && email.validity.customError) {
    email.setCustomValidity("");
  }
});

// novalidate means the browser never auto-validates; we drive it manually.
form.addEventListener("submit", (event) => {
  // Example of setting a custom message on submit (e.g. from a server check).
  if (email.value.endsWith("@example.com")) {
    email.setCustomValidity("Please use a non-example email address.");
  }

  // A single manual call surfaces whatever messages are currently set.
  if (!form.reportValidity()) {
    event.preventDefault(); // Block submission while any field is invalid.
  }
});

The guard email.validity.customError matters: it ensures you only clear messages you set yourself, never a native constraint like typeMismatch. Clearing unconditionally would also wipe genuine built-in errors, because setCustomValidity("") resets the entire custom slot regardless of what put it there.

setCustomValidity Reset: Option Reference

Option Type Default Purpose
setCustomValidity(message) string "" Any non-empty string marks the control invalid and becomes validationMessage; "" clears it.
validity.customError boolean false true only when a non-empty custom message is currently set — the guard for safe clearing.
validationMessage string "" The message the browser will display; empty when no error is active.
Listener event "input" Fires on every value change; the earliest reliable clear point. Use "change" only if you must defer to blur.
reportValidity() () => boolean Manually shows the current message bubble and returns overall validity.
checkValidity() () => boolean Same boolean without showing UI — see checkValidity vs reportValidity.

Verification Steps

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

test("custom message clears on the next input", async ({ page }) => {
  await page.goto("/signup");
  await page.fill("#email", "a@example.com");
  await page.click("button[type=submit]");

  const email = page.locator("#email");
  await expect(email).toHaveJSProperty("validationMessage", "Please use a non-example email address.");

  await email.type("z"); // one keystroke fires input
  await expect(email).toHaveJSProperty("validationMessage", "");
});

Edge Cases & Failure Modes

Clearing overwrites a live native constraint. If your handler calls setCustomValidity("") unconditionally, it also erases the message the browser would otherwise show for patternMismatch or typeMismatch. Fix: gate the clear behind element.validity.customError, as in the baseline, so native errors survive the keystroke. See Reading ValidityState Flags for distinguishing the flags.

The message reappears on the next submit. Clearing on input only resets the string; if your submit handler recomputes the same condition, the message returns immediately. That is correct for synchronous rules but wrong for asynchronous server checks — cache the server result and only re-apply it if the value has not changed since the check resolved.

Programmatic value changes never clear the message. Setting element.value from script does not fire an input event, so an autofill or a “reset” button that assigns values will leave a stale custom message in place. Fix: dispatch new Event("input") after any scripted value change, or call setCustomValidity("") directly in that code path. If you also render a visible message, keep it in step with accessible error messaging.

Scaling the Reset with a Delegated input Listener

Attaching one listener per control is fine for a two-field form, but it grows brittle as the form does: every new input needs its own registration, and dynamically inserted fields miss the wiring entirely. Event delegation collapses the whole form to a single listener on the <form> element, relying on the fact that input events bubble. One handler then clears whichever control the event actually originated from.

const form = document.querySelector<HTMLFormElement>("#signup")!;

// A single delegated listener covers every current AND future field.
form.addEventListener("input", (event) => {
  const field = event.target;

  // Narrow to the elements that actually expose the Constraint Validation API.
  if (
    !(field instanceof HTMLInputElement) &&
    !(field instanceof HTMLTextAreaElement) &&
    !(field instanceof HTMLSelectElement)
  ) {
    return;
  }

  // Same safety guard as before: only wipe a message we set ourselves.
  if (field.validity.customError) {
    field.setCustomValidity("");
  }
});

The instanceof narrowing is not ceremony. event.target is typed as EventTarget | null, and only the three form-control interfaces carry setCustomValidity and validity; a <button> or a wrapping <div> that happens to sit inside the form would otherwise blow up at runtime. Because the listener lives on the form rather than on any specific node, controls injected later — a repeating address row, a field revealed by a checkbox — inherit the reset behaviour for free, with no re-binding.

One caveat worth naming: delegation clears the message but does not re-run your submit-time rules. If a field’s custom error came from a rule that also depends on siblings, prefer the cross-field validation approach so a single keystroke cannot mask a relationship that is still broken.

Keeping an aria-live Announcement in Step

Native validity bubbles are visual and transient; they are not reliably surfaced to assistive technology, and they vanish on the next interaction. If you mirror the custom message into a visible container, that container has to be cleared on the same input event — otherwise the on-screen text and the field’s real validity state drift apart, and a screen-reader user hears an error that the form no longer considers active.

const emailError = document.querySelector<HTMLElement>("#email-error")!;

email.addEventListener("input", () => {
  if (email.validity.customError) {
    email.setCustomValidity("");
    emailError.textContent = "";       // clear the visible mirror in lockstep
    email.removeAttribute("aria-invalid");
  }
});

Wire the container as <span id="email-error" role="alert" aria-live="polite"></span> and point the field at it with aria-describedby="email-error". Writing text into a role="alert" region prompts the screen reader to announce it; emptying the region on the reset keeps that announcement from lingering. Toggling aria-invalid alongside the custom message gives non-visual users the same “this is now being corrected” signal that the disappearing :invalid outline gives sighted users. For the full pattern, see inline error messaging strategies.

Frequently Asked Questions

Why does my field stay invalid after the user fixes the value?

A custom validity string set with setCustomValidity() is sticky — the browser never removes it automatically. You must call setCustomValidity("") yourself, typically on the next input event, before the control is considered valid again.

Should I clear on the input event or the change event?

Use input for the most responsive feel, since it fires on every keystroke as the user corrects the field. Reserve change for cases where you deliberately want to wait until the field loses focus before removing the message.

Does setCustomValidity("") also clear native validation errors?

No — it only clears the custom slot. Native constraints like required or type="email" are re-evaluated independently, so once your custom message is empty the browser resumes reporting any built-in error on its own. Guard your clear with validity.customError to avoid clearing during a native failure.

Why does clearing on input not work when I set the value from JavaScript?

Assigning to element.value in script does not dispatch an input event, so your listener never runs and the custom message stays stuck. This bites autofill flows and "clear form" buttons. Either call setCustomValidity("") directly in that code path, or dispatch element.dispatchEvent(new Event("input", { bubbles: true })) so a delegated listener on the form still sees it.

Can I use one delegated listener on the form instead of one per field?

Yes. Because input events bubble, a single listener on the <form> element can clear the message on whichever control the event came from. Narrow event.target to HTMLInputElement, HTMLTextAreaElement, or HTMLSelectElement before touching setCustomValidity, and any field added to the form later inherits the reset with no extra wiring.

← Back to Custom Validity Messages