How to use setCustomValidity correctly
element.setCustomValidity(message) directly modifies the ValidityState.customError flag. Unlike checkValidity(), it does not execute validation logic; it only sets a persistent state that blocks form submission until explicitly cleared. The core rule is strict: passing an empty string ('') resets the flag to valid, while any non-empty string marks the field invalid and displays the message in the native UI.
1. State Lifecycle & Mandatory Reset Pattern
Validation state leakage occurs when custom errors persist after a user corrects their input. To prevent broken submission flows, you must reset the validity state on every interaction before evaluating new logic. This aligns with the broader constraint validation event sequence detailed in Mastering HTML5 Native Form Validation.
const clearCustomValidity = (input: HTMLInputElement): void => {
input.setCustomValidity('');
};
// Attach to form for event delegation
form.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
if (!target.matches('input, select, textarea')) return;
// 1. Always reset first
clearCustomValidity(target);
// 2. Apply custom logic
if (target.value && !isValid(target.value)) {
target.setCustomValidity('Invalid format. Please check your input.');
}
});
// Handle form reset globally
form.addEventListener('reset', () => {
Array.from(form.elements).forEach(el => {
if (el instanceof HTMLInputElement) clearCustomValidity(el);
});
});
2. Resolving Native Attribute Conflicts
Calling setCustomValidity() indiscriminately overrides or duplicates native HTML5 constraints (pattern, minlength, required). Always inspect element.validity properties first. Reserve custom validity for cross-field dependencies or complex business rules. For localized templating and internationalization strategies, refer to Custom Validity Messages.
const validateWithPriority = (input: HTMLInputElement): void => {
// Clear previous state
input.setCustomValidity('');
// 1. Skip if native constraint already failed
if (input.validity.valueMissing || input.validity.patternMismatch) {
return; // Let native UI handle it
}
// 2. Apply custom cross-field logic
const password = document.getElementById('password') as HTMLInputElement;
if (input.id === 'confirm-password' && input.value !== password.value) {
input.setCustomValidity('Passwords do not match.');
}
};
3. Async Validation & Debouncing Implementation
Network-dependent checks require careful timing to avoid race conditions and API spam. Implement a debounce wrapper with AbortController to cancel stale requests. Always resolve to '' on failure to prevent permanent form lockout.
let debounceTimer: ReturnType<typeof setTimeout>;
let activeController: AbortController | null = null;
const validateAsync = async (input: HTMLInputElement, url: string): Promise<void> => {
input.setCustomValidity('Checking availability...');
activeController?.abort();
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.name !== 'AbortError') {
console.warn('Async validation failed:', err);
input.setCustomValidity(''); // Fail open to prevent lockout
}
}
};
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => validateAsync(e.target as HTMLInputElement, '/api/check-username'), 400);
});
Debugging Race Conditions: Use performance.now() to log request start/end timestamps. If a late-arriving response overwrites a newer valid state, ensure your AbortController properly cancels pending fetches before state mutation.
4. Accessibility & Visual State Synchronization
setCustomValidity() does not automatically populate ARIA attributes. Screen readers like NVDA or JAWS will ignore custom messages unless you manually synchronize the DOM. Pair validity state with aria-invalid and aria-describedby.
const syncA11yState = (input: HTMLInputElement, errorSpan: HTMLSpanElement): void => {
const hasCustomError = input.validity.customError;
input.setAttribute('aria-invalid', String(hasCustomError));
if (hasCustomError) {
input.setAttribute('aria-describedby', errorSpan.id);
errorSpan.textContent = input.validationMessage;
errorSpan.style.display = 'block';
} else {
input.removeAttribute('aria-describedby');
errorSpan.textContent = '';
errorSpan.style.display = 'none';
}
};
CSS & UX Note: Use :user-invalid instead of :invalid to prevent premature red borders on page load. Trigger input.reportValidity() only after meaningful user interaction (e.g., blur or submit) to avoid interrupting initial focus.
5. Cross-Browser Debugging & Fallback Strategies
Engine-specific quirks can disrupt validation flows. Safari delays invalid event firing, requiring a microtask delay when calling reportValidity(). Firefox may clip custom tooltips; use CSS positioning workarounds if native UI breaks.
const debugValidityState = (input: HTMLInputElement): void => {
console.group('ValidityState Debug');
console.table(input.validity);
console.log('customError:', input.validity.customError);
console.groupEnd();
};
// Safari-safe reportValidity
const safeReportValidity = (input: HTMLInputElement): void => {
if (navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')) {
setTimeout(() => input.reportValidity(), 0);
} else {
input.reportValidity();
}
};
// Legacy fallback detection
const isConstraintAPIAvailable = typeof HTMLFormElement.prototype.checkValidity === 'function';
6. Production-Ready Module Architecture
Encapsulate validation logic in a scalable ES6 module. Use MutationObserver for dynamic fields, aggregate errors at the form level, and leverage requestAnimationFrame for non-blocking UI updates during heavy validation cycles.
export class FormValidator {
private form: HTMLFormElement;
private observer: MutationObserver | null = null;
constructor(formSelector: string) {
this.form = document.querySelector(formSelector)!;
this.init();
}
private init(): void {
this.form.addEventListener('input', this.handleInput.bind(this));
this.form.addEventListener('submit', this.handleSubmit.bind(this));
this.form.addEventListener('reset', this.handleReset.bind(this));
this.observer = new MutationObserver((mutations) => {
mutations.forEach((m) => {
if (m.type === 'childList') {
m.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches('input, select, textarea')) {
this.attachValidation(node as HTMLInputElement);
}
});
}
});
});
this.observer.observe(this.form, { childList: true, subtree: true });
}
private handleInput(e: Event): void {
const target = e.target as HTMLInputElement;
target.setCustomValidity('');
// Run field-specific validation...
}
private handleSubmit(e: Event): void {
const errors = Array.from(this.form.elements)
.filter(el => el instanceof HTMLInputElement && el.validity.customError);
if (errors.length) {
e.preventDefault();
requestAnimationFrame(() => (errors[0] as HTMLInputElement).focus());
}
}
private handleReset(): void {
Array.from(this.form.elements).forEach(el => {
if (el instanceof HTMLInputElement) el.setCustomValidity('');
});
}
public destroy(): void {
this.observer?.disconnect();
this.form.replaceWith(this.form.cloneNode(true));
}
}
Final Production Checklist: