Exact problem this page addresses: how to encapsulate async validation, error state, and SSR hydration safety into a single, reusable useFormField hook β without leaking stale async results into the UI after a component unmounts or a faster request supersedes a slower one.
Before diving in, make sure you understand the broader context in React Form Hook Architecture, which covers how individual field hooks compose into a full form pipeline.
State shape and reducer
The hook owns four atomic properties. Keeping them flat avoids deep-equality pitfalls in useEffect dependency arrays.
type FieldState = {
value: string;
touched: boolean; // true once the field has ever been blurred
validating: boolean; // true while an async check is in flight
error: string | null;
};
type FieldAction =
| { type: 'SET_VALUE'; payload: string }
| { type: 'SET_TOUCHED'; payload: boolean }
| { type: 'SET_VALIDATING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null };
function fieldReducer(state: FieldState, action: FieldAction): FieldState {
switch (action.type) {
case 'SET_VALUE': return { ...state, value: action.payload };
case 'SET_TOUCHED': return { ...state, touched: action.payload };
case 'SET_VALIDATING': return { ...state, validating: action.payload };
case 'SET_ERROR': return { ...state, error: action.payload };
default: return state;
}
}
Using useReducer here instead of multiple useState calls guarantees that co-dependent state updates (e.g. clearing error while setting validating: true) are committed in a single render rather than two.
Step-by-step walkthrough
The full hook is assembled in five stages. Each stage maps to a concept from the form validation lifecycle.
Stage 1 β Bind DOM events to dispatch
import { useReducer } from 'react';
const initialState: FieldState = {
value: '',
touched: false,
validating: false,
error: null,
};
function useFieldState() {
const [state, dispatch] = useReducer(fieldReducer, initialState);
// onChange never triggers validation directly β it only records the new value.
// Validation is the caller's responsibility (see Stage 2).
const onChange = (val: string) =>
dispatch({ type: 'SET_VALUE', payload: val });
// Mark the field as touched on blur so error messages appear after interaction.
const onBlur = () => dispatch({ type: 'SET_TOUCHED', payload: true });
// Clear error and cancel any in-progress validation indicator on re-focus.
const onFocus = () => {
if (!state.touched) dispatch({ type: 'SET_ERROR', payload: null });
dispatch({ type: 'SET_VALIDATING', payload: false });
};
return { state, dispatch, onChange, onBlur, onFocus };
}
Attach aria-invalid={!!state.error} and aria-describedby="field-error" to the input element. Screen readers must announce errors when onBlur fires β verify this with VoiceOver or NVDA before shipping.
Stage 2 β Async validation queue with AbortController
Overlapping promise resolutions corrupt field state in high-latency environments. The solution uses an incrementing request ID to identify and discard stale results.
import { useRef } from 'react';
// The shape every async validator must return.
type ValidateAsync = (
value: string,
signal: AbortSignal // Pass the signal so fetch() / XHR can honour cancellation.
) => Promise<{ error: string | null }>;
function useAsyncValidation(
dispatch: React.Dispatch<FieldAction>,
validateAsync: ValidateAsync
) {
// Increment on every new run; stale closures that hold an older ID are ignored.
const requestIdRef = useRef(0);
// AbortController for the currently active network request.
const controllerRef = useRef<AbortController | null>(null);
// Debounce timer handle β cleared before each new run.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const runValidation = (value: string) => {
// Cancel the previous debounce window and abort any in-flight request.
if (timerRef.current) clearTimeout(timerRef.current);
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const requestId = ++requestIdRef.current; // Capture the ID for this run.
dispatch({ type: 'SET_VALIDATING', payload: true });
timerRef.current = setTimeout(async () => {
try {
const result = await validateAsync(value, controller.signal);
// Only apply the result if no newer run has started since this one.
if (requestIdRef.current === requestId) {
dispatch({ type: 'SET_ERROR', payload: result.error });
}
} catch (err) {
// AbortError is expected β suppress it, propagate everything else.
if (
(err as Error).name !== 'AbortError' &&
requestIdRef.current === requestId
) {
dispatch({ type: 'SET_ERROR', payload: 'Validation failed' });
}
} finally {
// Clear the spinner only if we are still the active request.
if (requestIdRef.current === requestId) {
dispatch({ type: 'SET_VALIDATING', payload: false });
}
}
}, 300); // 300 ms debounce keeps network traffic low without feeling sluggish.
};
const cleanup = () => {
if (timerRef.current) clearTimeout(timerRef.current);
controllerRef.current?.abort(); // Prevent state updates after unmount.
};
return { runValidation, cleanup };
}
Debugging tip: open the Network panel, throttle to Slow 3G, and type rapidly. Only the final request should complete; previous ones should appear as (cancelled).
This pattern is closely related to implementing async email availability checks, which applies the same AbortController technique to a live uniqueness endpoint.
Stage 3 β SSR hydration guard
Server-rendered React does not execute useEffect. If async validation fires before React reconciles the hydrated DOM, Next.js or Remix will throw a checksum mismatch. Guard against this with useLayoutEffect.
import { useState, useLayoutEffect, useEffect } from 'react';
export function useFormField(validateAsync: ValidateAsync) {
// false on the server; flipped to true synchronously after first client paint.
const [hydrated, setHydrated] = useState(false);
const { state, dispatch, onChange, onBlur, onFocus } = useFieldState();
const { runValidation, cleanup } = useAsyncValidation(dispatch, validateAsync);
// useLayoutEffect fires before the browser paints β the hydrated flag is set
// before any child effects can read it, so validation is guaranteed to be
// suppressed during the server-rendered phase.
useLayoutEffect(() => {
setHydrated(true);
}, []);
// Clean up timers and abort controllers when the field unmounts.
useEffect(() => {
return cleanup;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Programmatic setValue bypasses the debounce for instant feedback (e.g. autofill).
const setValue = (val: string) => {
dispatch({ type: 'SET_VALUE', payload: val });
if (hydrated) runValidation(val);
};
const handleChange = (val: string) => {
onChange(val);
if (hydrated) runValidation(val);
};
return { state, setValue, handleChange, onBlur, onFocus };
}
QA step: run next build && next start, open view-source:, and compare the server-rendered markup to the hydrated DOM. No Hydration failed warning should appear in the console.
For a broader treatment of hydration mismatches across frameworks, see Hydration Sync for SSR Forms.
State machine diagram
The diagram below maps the four field lifecycle states to their transition triggers. The VALIDATING state can resolve to VALID or INVALID, or be superseded by a new CHANGE event that restarts the cycle.
Failure modes and edge cases
1. Autofill bypass
Browsers inject autofilled values via a synthetic change event that fires before Reactβs synthetic event system is ready. If hydrated is still false at that point, autofill silently skips validation.
Fix: listen for the native animationstart event fired by the browserβs autofill CSS animation and manually trigger runValidation when it fires:
useEffect(() => {
const el = inputRef.current;
if (!el) return;
const handler = () => { if (hydrated) runValidation(el.value); };
el.addEventListener('animationstart', handler);
return () => el.removeEventListener('animationstart', handler);
}, [hydrated]);
2. Stale closure over hydrated inside the debounce timer
The setTimeout callback closes over hydrated at the time runValidation is called. If hydration completes in the 300 ms debounce window, the closure still sees false.
Fix: store hydrated in a ref and read it inside the callback rather than closing over the state variable:
const hydratedRef = useRef(false);
useLayoutEffect(() => {
setHydrated(true);
hydratedRef.current = true; // Keep the ref in sync.
}, []);
Then guard with hydratedRef.current inside setTimeout.
3. Safari input event quirk on <input type="date">
Safari fires the input event for date pickers on every wheel scroll step, generating dozens of validation calls per second. The 300 ms debounce is insufficient.
Fix: increase the debounce to 600 ms specifically for type="date" inputs, or switch to onChange (which Safari defers until the picker is closed).
4. Missing AbortError guard in custom validateAsync implementations
If a custom validateAsync does not propagate the AbortSignal to its inner fetch(), the request completes even after the controller aborts. The request-ID guard still prevents the stale result from reaching the UI, but the network round-trip is wasted.
Fix: always pass signal to fetch:
const res = await fetch('/api/check-email', { signal }); // Not optional.
5. eslint-disable mask hiding a real exhaustive-deps bug
The useEffect(() => cleanup, []) pattern intentionally omits cleanup from the dependency array β the cleanup function reference changes on every render, and including it would restart the effect on each render. The lint suppression comment is correct but hides future mistakes if the effect body grows.
Fix: extract cleanup from the hook return value and memoize it with useCallback so the reference is stable.
Verification checklist
- Typing rapidly in Network β Slow 3G shows only the last request completing; earlier ones are
(cancelled)in the Network tab. - Unmounting the component mid-validation produces no
Can't perform a React state update on an unmounted componentwarning. view-source:markup matches the hydrated DOM in a Next.js / Remix production build β noHydration failedin the console.aria-invalidtoggles totrueafter blur with a validation error; toggles back tofalseafter the error is cleared.aria-busyreflectsstate.validatingβ confirmed with a screen reader or axe-core DevTools scan.- Autofill (Chromeβs address form, Safariβs password suggestion) triggers validation correctly.
role="alert"error container announces the error message to VoiceOver/NVDA without requiring focus.- No TypeScript errors on
strict: trueβ allpayloadtypes andFieldActionvariants are exhaustive.
FAQ
Q: How do I test async race conditions deterministically in CI?
Use page.route() in Playwright or cy.intercept() in Cypress to delay validation responses by 1β2 seconds. Fire rapid input events programmatically, then assert that only the final network request resolves and the earlier ones were cancelled. The requestIdRef counter is an internal implementation detail β test the observable outcome (error state reflects the last value typed), not the ref value.
Q: Can this hook work with Zod or Yup schemas?
Yes. The validateAsync and validateSync parameters are schema-agnostic interfaces. Wire Zodβs .parseAsync() or Yupβs .validate() to the async slot, and .safeParse() / .validateSync() (with { abortEarly: true }) to the sync fallback. See integrating Zod for schema validation for the full wiring pattern.
Q: Why useLayoutEffect instead of useEffect for the hydration guard?
useLayoutEffect fires synchronously after DOM mutations but before the browser paints. This means the hydrated flag is set before any useEffect in the same component tree reads it. If you used useEffect, there is a brief window during which a child effect could fire validation against server-rendered markup, causing a mismatch.
Q: How do I surface accessibility errors during degraded validation states?
Pair aria-invalid with aria-describedby pointing to an error container. During timeout fallback, dispatch a validation:degraded custom event and update a role="alert" region. The alert fires without moving focus, so the user can keep typing. Use aria-busy={state.validating} on the input to signal ongoing checks.
Related
- React Form Hook Architecture β the parent context for how this hook fits into a complete form pipeline
- Hydration Sync for SSR Forms β deeper treatment of server/client reconciliation across frameworks
- Asynchronous Validation Strategies β patterns for debouncing, cancellation, and retry at the validation-layer level
- Error State Mapping Patterns β how to propagate field-level errors up to form-level and UI components