Form Validation Patterns
Good validation helps users complete forms, not fight them. Synchronous rules catch local issues such as required fields, length, and format. Asynchronous validation checks server-owned facts such as username availability. Treat them differently so the form feels responsive and fair.
Field-Level Sync Validation
Run sync validation on blur, then on change after the field has been touched. This avoids showing errors before the user has a chance to type.
type FieldState = {
value: string;
touched: boolean;
error: string | null;
};
export function validateEmail(value: string): string | null {
if (!value.trim()) return "Email is required.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Enter a valid email.";
return null;
}
export function onEmailBlur(field: FieldState): FieldState {
return {
...field,
touched: true,
error: validateEmail(field.value),
};
}
Debounced Async Validation
Async validation should run only after sync validation passes. Otherwise the app wastes server calls checking values that are already invalid locally.
export function createUsernameValidator(
checkAvailability: (username: string) => Promise<boolean>,
delayMs = 350,
) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (username: string) =>
new Promise<string | null>((resolve) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const available = await checkAvailability(username);
resolve(available ? null : "Username is already taken.");
}, delayMs);
});
}
Accessible Errors
Connect each error message to its input with aria-describedby, and set aria-invalid when an error exists. Form-level errors belong in a summary near the top, ideally in a focusable or announced region after submission fails.
export function inputA11yProps(fieldId: string, error: string | null) {
return {
id: fieldId,
"aria-invalid": error ? "true" : "false",
"aria-describedby": error ? `${fieldId}-error` : undefined,
};
}
NOTE
Run sync validation eagerly (on blur), async validation lazily (debounced, only after sync passes). Never block the user with a spinner on a field they haven’t touched yet.
Further Learning
Search these terms to go deeper:
- “aria-describedby form validation” — connecting errors to fields accessibly
- “debounced async validation username” — server-backed field checks
- “form error summary screen reader” — accessible submission failure patterns
- “schema validation client server shared” — keeping rules consistent across boundaries