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);
};

Scaling to Many Fields with a Declarative Rule Map

The imperative if chains above read cleanly for one or two rules, but they degrade fast once a form carries a dozen custom constraints. A more maintainable shape is a rule map: each field id points to a pure predicate plus the message to show when that predicate fails. The event handler stays generic — clear, look up the rule, apply — and adding a constraint becomes a one-line data change rather than a new branch in shared code.

type Rule = {
  test: (value: string, form: HTMLFormElement) => boolean; // returns true when VALID
  message: string;
};

// Each entry is data, not control flow. Predicates return true for a passing value.
const rules: Record<string, Rule> = {
  'confirm-password': {
    test: (value, form) =>
      value === (form.elements.namedItem('password') as HTMLInputElement).value,
    message: 'Passwords do not match.',
  },
  'coupon': {
    test: (value) => value === '' || /^[A-Z0-9]{6,12}$/.test(value),
    message: 'Coupon codes are 6–12 uppercase letters or digits.',
  },
};

function applyRule(input: HTMLInputElement, form: HTMLFormElement): void {
  input.setCustomValidity(''); // Reset before every evaluation — the invariant never changes.

  // Native constraints still take priority; skip custom logic while one is failing.
  if (!input.validity.valid && !input.validity.customError) return;

  const rule = rules[input.id];
  if (rule && !rule.test(input.value, form)) {
    input.setCustomValidity(rule.message);
  }
}

Because every predicate returns true for a valid value, the handler never needs to know what a rule means — only whether it passed. That inversion keeps the clear-then-reapply invariant in exactly one place, which is the single most reliable way to avoid the “locked invalid” bug at scale.

Keeping Custom Validity in Sync Across Dependent Fields

A subtle failure mode with cross-field rules is that validity is evaluated per control, but the truth of a rule can change when a different field is edited. Editing password should re-run the check on confirm-password, yet the browser only fires events on the field the user actually touched. If you validate solely the edited element, the confirmation field keeps a stale customError — either falsely invalid after the mismatch is resolved, or falsely valid after it is reintroduced.

// Map each field to the dependents whose validity hinges on it.
const dependents: Record<string, string[]> = {
  password: ['confirm-password'],
};

form.addEventListener('input', (e: Event) => {
  const target = e.target as HTMLInputElement;
  if (!target.id) return;

  applyRule(target, form);

  // Re-validate anything that references the field just edited.
  for (const depId of dependents[target.id] ?? []) {
    const dep = form.elements.namedItem(depId) as HTMLInputElement | null;
    if (dep && dep.value !== '') applyRule(dep, form); // Skip untouched empties.
  }
});

Guarding on dep.value !== '' matters: re-running a rule against a field the user has not filled yet would surface a mismatch error before they have had any chance to type, which reads as a broken form. The dependency map keeps that reactivity explicit and auditable rather than scattering ad-hoc cross-references through unrelated handlers.

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.

Does calling setCustomValidity() repeatedly with the same message hurt performance?

No. The call is a cheap synchronous property write with no layout or reflow cost — it does not repaint or reposition the native tooltip on its own. The visible cost only arrives when you call reportValidity(), which is why clearing and reapplying on every input event is safe even for fast typists. Keep the clear-first pattern unconditional; there is no measurable penalty to setting the flag to a value it already holds.

← Back to Custom Validity Messages