Synchronous validation is the backbone of responsive form UX: it evaluates constraints on the main thread with zero latency, giving users instant feedback on format, length, and structural rules before a single network byte is sent. The failure mode this pattern prevents is deferred error display β where users complete an entire form, submit it, wait for a round-trip, and only then discover they mistyped a phone number in field two. That experience collapse can be avoided entirely by running deterministic checks inline.
This page covers the state machine, a production-ready TypeScript implementation, the browser-specific edge cases that break naive approaches, and the ARIA wiring you need to make validation accessible. Where rules depend on server state (username availability, email deliverability), hand off to asynchronous validation strategies β those patterns handle AbortController cancellation and race conditions that synchronous code cannot. For the schema-level constraint layer that sits above both, see integrating Zod for schema validation.
State Machine Specification
Synchronous validation follows a tight four-state model. The key design decision is that VALIDATING is instantaneous β there is no pending I/O β so the machine skips directly from IDLE to VALID or INVALID without a loading state.
| State | Enters when | aria-invalid | Error visible |
|---|---|---|---|
IDLE |
Field is pristine (never focused) | not set | no |
VALID |
Rules pass after onChange / onBlur |
false |
no |
INVALID |
Any rule fails after onChange / onBlur |
true |
yes |
SUBMIT_BLOCKED |
onSubmit fires while any field is INVALID or IDLE |
true on each invalid field |
yes (all fields forced-evaluated) |
The SUBMIT_BLOCKED state forces all IDLE fields into evaluation β users who tab-skip optional fields must still see errors on submission. Storing state as a discriminated union (rather than separate boolean flags) prevents impossible combinations like { isValid: true, error: 'Required' }.
Core Implementation
The validator adapter is intentionally framework-agnostic. Framework-specific event wiring sits outside this boundary; the adapter only maps a FieldState to a new FieldState. This separation means the same validation rules work identically in React, Vue, and Svelte without modification.
// βββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type SyncState = 'IDLE' | 'VALID' | 'INVALID';
export interface FieldState<T> {
value: T;
validationState: SyncState;
error: string | null;
/** true once the user has interacted with the field at least once */
isDirty: boolean;
}
/**
* A validation rule is a pure function: value in, error string or null out.
* Returning null means the rule passes.
*/
export type ValidationRule<T> = (value: T) => string | null;
// βββ Adapter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* createSyncValidator returns a reducer that maps a FieldState to a new
* FieldState by running each rule in order (fail-fast on first violation).
*
* No side effects, no I/O β safe to call inside any framework event handler
* or inside a useMemo/computed without triggering re-renders from within.
*/
export function createSyncValidator<T>(rules: ValidationRule<T>[]) {
return function validate(state: FieldState<T>): FieldState<T> {
// Pristine fields remain IDLE; only evaluate after the first interaction.
if (!state.isDirty) return state;
let error: string | null = null;
for (const rule of rules) {
const result = rule(state.value);
if (result !== null) {
error = result;
break; // fail-fast: surface the first violated rule, not all of them
}
}
return {
...state,
error,
validationState: error === null ? 'VALID' : 'INVALID',
};
};
}
// βββ Event reducer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
type FormEvent =
| { type: 'CHANGE'; field: string; payload: unknown }
| { type: 'BLUR'; field: string }
| { type: 'SUBMIT' }
| { type: 'RESET' };
type FormState = Record<string, FieldState<unknown>>;
/**
* Pure reducer: handles all four event types and returns the next FormState.
* The SUBMIT case force-validates every field regardless of dirty status so
* that users who never touched a required field still see the error.
*/
export function formReducer(
state: FormState,
event: FormEvent,
validate: (s: FieldState<unknown>) => FieldState<unknown>
): FormState {
switch (event.type) {
case 'CHANGE':
return {
...state,
[event.field]: validate({
...state[event.field],
value: event.payload,
isDirty: true,
}),
};
case 'BLUR':
// Re-run rules on blur even if value hasn't changed; handles the case
// where the user focuses and immediately leaves a required field.
return {
...state,
[event.field]: validate({
...state[event.field],
isDirty: true,
}),
};
case 'SUBMIT':
// Force isDirty=true on every field to surface errors on untouched fields.
return Object.fromEntries(
Object.entries(state).map(([key, fieldState]) => [
key,
validate({ ...fieldState, isDirty: true }),
])
);
case 'RESET':
return Object.fromEntries(
Object.entries(state).map(([key, fieldState]) => [
key,
{ ...fieldState, isDirty: false, error: null, validationState: 'IDLE' as const },
])
);
default:
return state;
}
}
Common rule implementations
// Rules are portable: the same function works across every field of the same type.
export const required: ValidationRule<string> = (v) =>
v.trim().length === 0 ? 'This field is required.' : null;
export const minLength =
(min: number): ValidationRule<string> =>
(v) =>
v.length < min ? `Must be at least ${min} characters.` : null;
export const maxLength =
(max: number): ValidationRule<string> =>
(v) =>
v.length > max ? `Cannot exceed ${max} characters.` : null;
/**
* Email format check. Deliberately lenient β only rejects obvious non-emails.
* Do NOT use a 254-character RFC-5321 mega-regex; it backtracks catastrophically
* on inputs like "aaaaaaaaaaaaaaaaaaaaa@" fed through a stress test.
*/
export const emailFormat: ValidationRule<string> = (v) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : 'Enter a valid email address.';
/**
* Range check for numeric inputs. Note: input.value is always a string,
* so parse before comparison β never compare a string to a number directly.
*/
export const numericRange =
(min: number, max: number): ValidationRule<string> =>
(v) => {
const n = Number(v); // Number('') returns 0; handle empty separately
if (v === '' || Number.isNaN(n)) return 'Enter a valid number.';
if (n < min || n > max) return `Must be between ${min} and ${max}.`;
return null;
};
Integration Guidance
This synchronous layer slots into the broader validation logic & schema integration pipeline as the first evaluation pass. The pipeline order is:
- Synchronous rules (this page) β format, length, range; zero latency.
- Schema-level coercion β if you use Zod schema validation, run
schema.safeParse()against the normalized value after synchronous rules pass. Zodβs error map translates directly to yourFieldState.errorshape. - Asynchronous uniqueness checks β only fire after synchronous and schema passes; cancel previous in-flight requests via AbortController. See asynchronous validation strategies.
- Cross-field dependency re-evaluation β when field Aβs value affects field Bβs validity, consult cross-field dependency logic for the dependency graph pattern that prevents cascading re-renders.
For React specifically, debouncing validation triggers shows how to wrap the synchronous reducer call in a debounce boundary that keeps keystroke feedback immediate on onBlur while deferring the heavier per-keystroke re-render pass.
Edge Cases and Failure Modes
Number inputs always yield strings
HTMLInputElement.value is always a string, even for <input type="number">. Comparing state.value > 10 where state.value is "9" evaluates to false in JavaScript because "9" > 10 coerces the string. Always parse with Number() or parseFloat() before numeric comparisons, and validate the result is not NaN.
// Wrong β string comparison; "9" > "10" is true because "9" > "1" lexicographically
if (state.value > '10') { ... }
// Correct
const n = Number(state.value);
if (Number.isNaN(n) || n > 10) { ... }
Date input normalization
<input type="date"> returns an ISO string ("YYYY-MM-DD") or an empty string if the browser cannot parse the userβs entry. Construct a Date object and check isNaN(date.getTime()) β do not rely on the string format alone, because Safari and Firefox handle partial dates differently.
export const validDate: ValidationRule<string> = (v) => {
if (!v) return 'Date is required.';
const d = new Date(v);
// new Date('invalid') returns a Date object, but getTime() returns NaN
return Number.isNaN(d.getTime()) ? 'Enter a valid date.' : null;
};
Locale-aware decimal separators
parseFloat("1,5") silently returns 1 in all JavaScript engines β the comma is ignored. Users in most of continental Europe, South America, and parts of Asia use , as the decimal separator. Normalize before parsing:
function normalizeDecimal(v: string, locale: string): number {
// Detect whether this locale uses comma as decimal separator
const sample = (1.1).toLocaleString(locale);
const decimalSep = sample.includes(',') ? ',' : '.';
const normalized = decimalSep === ',' ? v.replace(',', '.') : v;
return parseFloat(normalized);
}
Checkbox and radio group collection
For checkbox groups, .value on a single element only returns the value of that element β not the full selection. Collect the group state correctly:
function getCheckedValues(name: string, form: HTMLFormElement): string[] {
const inputs = form.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][name="${name}"]`
);
return Array.from(inputs)
.filter((el) => el.checked)
.map((el) => el.value);
}
Shadow DOM boundaries
form.querySelectorAll does not pierce shadow roots. If your design system renders inputs inside web components, you cannot traverse to them with standard DOM queries. Either expose a validate() method on the componentβs public API, or use a form-associated custom element that implements ElementInternals and participates in constraint validation natively.
Troubleshooting Reference
| Failure scenario | Diagnostic step | Recovery action |
|---|---|---|
| Error message shows after correct input | Check whether isDirty is being reset on re-render. Add a console trace to validate() and confirm state.isDirty is true. |
Store isDirty in a ref or reducer β never re-initialize it from props on each render. |
onBlur never fires on mobile Safari |
Test on a real iOS device. Mobile Safari does not fire blur on non-focusable elements, and some touch events swallow focus events. |
Add a touchend handler that manually calls onBlur for elements that do not receive native focus events. |
| All fields show errors on mount | The SUBMIT reducer path is running before the user interacts. Check whether an onMount / useEffect is calling the submit handler. |
Gate the SUBMIT evaluation behind an explicit user gesture check (e.g. hasAttemptedSubmit boolean in state). |
| Stale error persists after value corrects | Rules are cached in a closure that captured the old value. Confirm the validate() call receives the new state object, not the old one. |
Pass the full updated FieldState to validate() on every CHANGE event β never mutate the existing state object. |
| Regex rule freezes the browser tab | A catastrophic backtracking regex is running on an adversarial value. Open the browser profiler, find the long synchronous task, and examine the stack trace. | Replace the regex with a linear-time alternative or add a length guard (if (v.length > 256) return 'Too long.') before evaluating the pattern. |
Testing and QA Hooks
Hard-coding CSS class names or text content into Playwright/Cypress selectors creates brittle tests that break on design iteration. Use data-* attributes that encode validation semantics directly.
<!-- Attach these in your rendering layer, keyed to FieldState.validationState -->
<input
name="email"
data-field="email"
data-validation-state="INVALID"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error" role="alert" data-error-for="email">
Enter a valid email address.
</p>
// Playwright selector pattern β survives CSS refactors and copy changes
await expect(page.locator('[data-error-for="email"]')).toBeVisible();
await expect(page.locator('[data-field="email"]')).toHaveAttribute(
'data-validation-state',
'INVALID'
);
await expect(page.locator('[data-field="email"]')).toHaveAttribute(
'aria-invalid',
'true'
);
For ARIA regression coverage, integrate axe-core into your Playwright suite and run it after every validation state transition:
import AxeBuilder from '@axe-core/playwright';
test('email field error is accessible', async ({ page }) => {
await page.fill('[name="email"]', 'not-an-email');
await page.locator('[name="email"]').blur();
const results = await new AxeBuilder({ page })
.include('[data-field="email"]')
.analyze();
expect(results.violations).toHaveLength(0);
});
Common Pitfalls
Catastrophic backtracking regex. Complex patterns with nested quantifiers β (a+)+, ([a-z]*)*, (a|aa)+ β exhibit exponential time complexity on adversarial inputs. A 30-character string can hang the browser tab for seconds. Benchmark every regex with worst-case input before shipping, and prefer linear-time parsers for email, URL, and phone patterns.
Stale error after value correction. When isDirty is initialized from a prop on each render cycle (instead of being held in persistent state), the flag resets to false mid-session. The validator skips evaluation and the stale error from the previous run remains visible. Hold isDirty in a ref or reducer that survives re-renders.
Over-validating the entire form on each keystroke. Running all field rules on every INPUT_CHANGE event scales as O(fields Γ rules). Build an explicit dependency map: { email: ['email', 'confirmEmail'], password: ['password', 'confirmPassword'] }. Only re-validate fields listed as dependents of the changed field.
Skipping ARIA sync on state transition. Updating the error message text without updating aria-invalid on the input means screen readers announce errors in the live region but the fieldβs accessible state remains stale. Always update both aria-invalid and the live region message atomically.
Comparing input.value to a typed value without parsing. JavaScriptβs loose equality coerces types in unexpected ways. "0" == false, "" == 0, and "1" > "10" are all truthy. Validate type-narrowed values, not raw DOM strings.
Frequently Asked Questions
When should synchronous validation be prioritized over asynchronous checks?
Use synchronous rules for every constraint that can be evaluated without I/O: required checks, format patterns, length bounds, range checks, and structural rules (e.g. password character class requirements). Reserve asynchronous checks β which carry network latency and race-condition risk β for constraints that genuinely require server knowledge: username availability, email deliverability, coupon code validity, and inventory state.
A practical gate: if you could evaluate the rule offline with no data beyond the current form values, it belongs in the synchronous pass.
How do I handle cross-field dependencies synchronously?
Maintain a dependency graph alongside your form state: a Record<string, string[]> that maps each field name to the list of fields that must be re-validated when it changes. When the CHANGE event fires for password, look up its dependents (['confirmPassword']) and run the validator for each.
Avoid re-evaluating the entire form on a single keystroke β on a form with 20 fields and 5 rules each, that is 100 synchronous function calls per character typed.
What is the recommended approach for accessibility in synchronous validation?
Set aria-invalid="true" on the input whenever validationState === 'INVALID' and aria-invalid="false" when VALID. Associate the error message element with the input via aria-describedby pointing to the error elementβs id. Place role="alert" or aria-live="polite" on the error container so screen readers announce the message when it appears.
Do not use window.alert() for validation errors β it steals focus and disrupts the userβs flow. Do not programmatically move focus to the first error on onChange; only move focus on an explicit submit attempt, and only to the first error in document order.
Can synchronous regex patterns cause performance problems?
Yes, and it happens silently in production. A regex like /^(\d+\.?)+$/ against the input "1111111111b" will cause catastrophic backtracking: the engine tries every possible combination of the inner group before giving up, resulting in exponential time. Typical symptoms are a frozen browser tab with no JavaScript error. Run eslint-plugin-regexp with the regexp/no-super-linear-backtracking rule in CI to catch vulnerable patterns before they reach production.
Related
- Debouncing Validation Triggers in React β balance instant blur feedback with batched keystroke evaluation
- Asynchronous Validation Strategies β server-dependent checks with AbortController and race-condition prevention
- Integrating Zod for Schema Validation β schema-level type coercion and error mapping that feeds into this pipeline
- Cross-Field Dependency Logic β dependency graphs for fields whose validity depends on sibling values