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.

useFormField state machine Four states: IDLE, VALIDATING, VALID, and INVALID. IDLE transitions to VALIDATING when the user changes the field value. VALIDATING transitions to VALID when the async check passes or to INVALID when it fails. Any VALID or INVALID state returns to VALIDATING on a new change event. Focus clears error and resets INVALID to IDLE. IDLE VALIDATING VALID INVALID onChange passes fails onChange onChange onFocus (clears error)

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 component warning.
  • view-source: markup matches the hydrated DOM in a Next.js / Remix production build β€” no Hydration failed in the console.
  • aria-invalid toggles to true after blur with a validation error; toggles back to false after the error is cleared.
  • aria-busy reflects state.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 β€” all payload types and FieldAction variants 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