How to Use setCustomValidity Correctly

element.setCustomValidity(message) directly sets the ValidityState.customError flag and stores a message string; passing a non-empty string marks the control invalid until you explicitly clear it by passing an empty string (''), and because the flag never resets on its own, the single rule that prevents every common bug is: clear first, then reapply only when a check fails.

When to Use This Recipe

Use setCustomValidity() when a rule cannot be expressed with native HTML5 attributes — cross-field comparisons, asynchronous availability checks, or domain-specific business logic. For anything required, pattern, type, min/max, or minlength/maxlength can already express, lean on those native constraints and read their flags instead, as covered in the Constraint Validation API Deep Dive. This recipe is the implementation detail behind authoring Custom Validity Messages, and it operates inside the canonical <form novalidate> + reportValidity() pattern from Mastering HTML5 Native Form Validation.

The clear-then-reapply pattern for setCustomValidity On every input event, first call setCustomValidity empty string to clear, then run checks, then reapply a message only if a rule fails. Skipping the clear step locks the field invalid. input event user types setCustomValidity('') clear first — always rule fails → set('message') rule passes → stays valid
Every input event clears the flag before re-evaluating; reapplying a message is conditional on a fresh failure.

State Lifecycle & the Mandatory Reset

Validation state leakage happens when a custom error persists after the user corrects their input. Because setCustomValidity() does not run any logic — it only sets a persistent flag — you must reset it on every interaction before evaluating new rules.

function clearCustomValidity(input: HTMLInputElement): void {
  input.setCustomValidity('');
}

// Event delegation on the form catches every control, including injected ones.
form.addEventListener('input', (e: Event) => {
  const target = e.target as HTMLInputElement;
  if (!target.matches('input, select, textarea')) return;

  clearCustomValidity(target);                 // 1. Always reset first.
  if (target.value && !isAllowed(target.value)) {
    target.setCustomValidity('Invalid format. Please check your input.'); // 2. Reapply.
  }
});

// A form reset must also clear leftover custom state.
form.addEventListener('reset', () => {
  for (const el of form.elements) {
    if (el instanceof HTMLInputElement) clearCustomValidity(el);
  }
});

Resolving Native Attribute Conflicts

Calling setCustomValidity() blindly can mask native constraints (required, pattern, minlength) that the browser already detected. Inspect element.validity first and let native checks win unless your rule is genuinely additional — reserve custom validity for cross-field dependencies and business logic. Translating the native flags into wording is the job of Custom Validity Messages.

function validateWithPriority(input: HTMLInputElement): void {
  input.setCustomValidity(''); // Clear previous custom state.

  // Defer to the browser if a native constraint already failed.
  if (input.validity.valueMissing || input.validity.patternMismatch) return;

  // Apply custom cross-field logic only when native checks pass.
  const password = document.getElementById('password') as HTMLInputElement;
  if (input.id === 'confirm-password' && input.value !== password.value) {
    input.setCustomValidity('Passwords do not match.');
  }
}

Async Validation Without Locking the Field

Network checks need careful timing to avoid race conditions. Debounce the handler, cancel stale requests with an AbortController, and crucially never set a non-empty message to indicate “loading” — that immediately marks the field invalid. Indicate progress with aria-busy instead, and always fail open (clear the message) on error so a network blip never locks submission. This mirrors the asynchronous server checks approach.

let debounceTimer: ReturnType<typeof setTimeout>;
let activeController: AbortController | null = null;

async function validateAvailability(input: HTMLInputElement, url: string): Promise<void> {
  input.setCustomValidity('');        // Clear stale result while checking.
  input.setAttribute('aria-busy', 'true'); // Signal loading without invalidating.

  activeController?.abort();            // Cancel any in-flight request.
  activeController = new AbortController();

  try {
    const res = await fetch(url, { signal: activeController.signal });
    const data = await res.json();
    input.setCustomValidity(data.isTaken ? 'Username already exists.' : '');
  } catch (err) {
    if ((err as Error).name !== 'AbortError') {
      console.warn('Async validation failed:', err);
      input.setCustomValidity(''); // Fail open — never lock the user out.
    }
  } finally {
    input.removeAttribute('aria-busy');
  }
}

input.addEventListener('input', (e) => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(
    () => validateAvailability(e.target as HTMLInputElement, '/api/check-username'),
    400,
  );
});

Accessibility & Visual State Synchronization

setCustomValidity() populates no ARIA attributes; screen readers ignore the message unless you mirror it into the DOM. Pair customError with aria-invalid and aria-describedby, following the live-region patterns in UX Patterns & Error State Design.

function syncA11yState(input: HTMLInputElement, errorEl: HTMLElement): void {
  const hasError = input.validity.customError;
  input.setAttribute('aria-invalid', String(hasError));
  if (hasError) {
    input.setAttribute('aria-describedby', errorEl.id);
    errorEl.textContent = input.validationMessage;
    errorEl.hidden = false;
  } else {
    input.removeAttribute('aria-describedby');
    errorEl.textContent = '';
    errorEl.hidden = true;
  }
}

Style with :user-invalid rather than :invalid to avoid red borders on load, and only call input.reportValidity() after meaningful interaction (blur or submit) so you never interrupt initial focus — the timing rationale is in checkValidity vs reportValidity differences.

Parameter Reference

Call Effect on customError Blocks submission Use for
setCustomValidity('message') Set true Yes A failed custom rule
setCustomValidity('') Set false No Clearing before re-evaluation; passing a rule
reading validationMessage unchanged n/a Mirroring the active error into your UI
reading validity.customError unchanged n/a Branching ARIA / styling on custom state

Verification Steps

  1. In DevTools, run $0.setCustomValidity('test') on a field, then $0.validity.customError — it returns true and $0.checkValidity() returns false.
  2. Run $0.setCustomValidity('') and re-check — customError is false and the field validates again.
  3. Confirm your handler clears first: type an invalid value, correct it, and verify submission is no longer blocked.
import { test, expect } from '@playwright/test';

test('corrected field no longer blocks submission', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('#confirm-password', 'wrong');
  await page.fill('#confirm-password', 'Matches123!'); // correct it
  await page.click('#submit');
  await expect(page).toHaveURL(/success/);
});

Edge Cases & Failure Modes

Field locked invalid forever. Cause: a message was set but never cleared. Fix: call setCustomValidity('') at the top of every validation pass, before any new check.

Stale async result overwrites a newer value. Cause: a late response resolves after the user has already changed the input. Fix: cancel pending fetches with AbortController before mutating state, and ignore AbortError.

Safari delays the invalid event. Cause: WebKit timing on programmatic reportValidity(). Fix: defer the call to the next task only where timing is non-deterministic; in submit handlers call it directly.

// Use sparingly — prefer a direct call in submit handlers.
const deferredReportValidity = (input: HTMLInputElement): void => {
  setTimeout(() => input.reportValidity(), 0);
};

Frequently Asked Questions

Why must I pass an empty string instead of null to clear?

The API treats any truthy string as an error message, and only the exact empty string '' resets the customError flag to valid. Passing null or undefined is coerced to the strings "null"/"undefined", which keeps the field invalid. Always clear with a literal empty string.

Does setCustomValidity() show the message by itself?

No. It only sets state. The message surfaces when you call reportValidity() (native tooltip) or when you mirror validationMessage into your own accessible container. With novalidate on the form, the native popup is suppressed, so rendering your own element is what makes the message visible.

Can I set custom validity on a checkbox or radio group?

Yes, but set it on a single representative control in the group (typically the first), since the browser anchors the tooltip to one element. Clear it on that same element when any option in the group changes, and reflect the error in your own grouped message container for assistive technology.

← Back to Custom Validity Messages