React Hook Form Async Field Validation

This recipe implements field-level asynchronous validation in React Hook Form — a username-availability check — using register’s validate function returning a Promise, debounced input, and AbortController cancellation so a slow response for an older keystroke can never overwrite a newer one.

When to Use This Recipe

Use an async field validator when correctness depends on the server: username/email availability, coupon validity, or any uniqueness check. Keep purely syntactic rules (format, length) synchronous in the schema — only the network round-trip belongs here. The state-machine reasoning behind this boundary lives in Asynchronous Server Checks, and this page applies it inside the React Hook Form Validation lifecycle.

Debounce and abort timeline for async field validation Two keystrokes arrive close together. The first schedules a request after the debounce delay, but the second keystroke aborts it and schedules its own, so only the latest result reaches formState. time keystroke "ali" keystroke "alic" debounce (aborted) debounce → fetch → result only latest result → formState
A newer keystroke aborts the older request's debounce and fetch, guaranteeing only the most recent input's result is committed.

Minimal Complete Working Example

The validator factory owns the debounce timer and the AbortController. RHF’s validate awaits the returned promise; the resolved string becomes errors.username.message, and true clears it.

import { useForm } from 'react-hook-form';
import { useMemo, useId } from 'react';

type Values = { username: string };

// Factory: one debounce timer + one AbortController per field instance.
function createUsernameValidator(delayMs = 400) {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let controller: AbortController | null = null;

  return (value: string): Promise<true | string> =>
    new Promise((resolve) => {
      if (value.length < 3) return resolve('At least 3 characters'); // sync guard
      if (timer) clearTimeout(timer);
      controller?.abort();               // cancel the previous in-flight request
      controller = new AbortController();

      timer = setTimeout(async () => {
        try {
          const res = await fetch(
            `/api/username-available?u=${encodeURIComponent(value)}`,
            { signal: controller!.signal },
          );
          const { available } = (await res.json()) as { available: boolean };
          resolve(available ? true : 'That username is taken');
        } catch (err) {
          // A newer keystroke aborted us — let the newer call own the result.
          if ((err as Error).name === 'AbortError') return;
          resolve('Could not check availability — try again');
        }
      }, delayMs);
    });
}

export function UsernameForm() {
  // Stable across re-renders so the timer/controller persist.
  const validateUsername = useMemo(() => createUsernameValidator(), []);
  const id = useId();
  const errId = `${id}-err`;

  const {
    register, handleSubmit,
    formState: { errors, isValidating, isSubmitting },
  } = useForm<Values>({ mode: 'onChange', defaultValues: { username: '' } });

  const err = errors.username;
  return (
    <form noValidate onSubmit={handleSubmit(async (v) => { /* submit v */ })}>
      <div className="form-group">
        <label htmlFor={id}>Username</label>
        <input
          id={id}
          aria-invalid={err ? 'true' : undefined}
          aria-describedby={err ? errId : undefined}
          {...register('username', { validate: validateUsername })}
        />
        <p id={errId} role="alert" className="error-container" aria-live="polite">
          {isValidating ? 'Checking availability…' : err?.message}
        </p>
      </div>
      <button type="submit" disabled={isSubmitting || isValidating}>Sign up</button>
    </form>
  );
}

Parameter Reference

Parameter Type Purpose
validate (value) => Promise<true | string> RHF async rule; resolve true to pass, a string to fail
delayMs number Debounce window; 300–500ms balances latency vs request volume
AbortController.signal AbortSignal Passed to fetch so a newer call cancels the older
mode: 'onChange' RHF option Runs the async validator as the user types
formState.isValidating boolean true while the promise is pending — drives the spinner
controller.abort() method Cancels the prior request; its fetch rejects with AbortError

Verification Steps

  1. DevTools Network. Type quickly into the field with the Network panel open. You should see superseded requests show as “(canceled)” — confirming the AbortController fires — and only the final keystroke’s request complete.
  2. Pending state. Confirm the submit button is disabled while isValidating is true, so a submission cannot race ahead of an unresolved check.
  3. Playwright assertion.
import { test, expect } from '@playwright/test';

test('taken username surfaces an accessible error', async ({ page }) => {
  await page.route('**/api/username-available*', (route) =>
    route.fulfill({ json: { available: false } }));
  await page.goto('/signup');
  await page.getByLabel('Username').fill('alice');
  const field = page.getByLabel('Username');
  await expect(field).toHaveAttribute('aria-invalid', 'true');
  await expect(page.getByRole('alert')).toHaveText('That username is taken');
});

Edge Cases & Failure Modes

Recreating the validator every render. If createUsernameValidator() is called inline in JSX, each render gets a fresh timer and controller, so debouncing and cancellation break. Wrap it in useMemo(() => createUsernameValidator(), []) (or a useRef) so the closure persists across renders.

Submitting during a pending check. Without gating, a user can submit while the availability request is unresolved and pass validation against stale state. Disable submit on isValidating (as above) and re-run the check server-side at submit time as the authoritative gate — the client check is a UX accelerator, not a security boundary.

Treating AbortError as a real failure. Catching every rejection and resolving an error string would flash “Could not check availability” on every fast keystroke. Detect err.name === 'AbortError' and return without resolving, letting the newer call own the outcome.

Why the Validator Resolves Instead of Rejecting

A subtle contract underpins the whole recipe: React Hook Form’s async validate distinguishes a validation failure from a thrown error. When your promise resolves with a string, RHF treats that string as errors.username.message and marks the field invalid — the expected, recoverable path. When your promise rejects, RHF does not catch it for you; the rejection escapes into the surrounding microtask and surfaces as an unhandled promise rejection, leaving formState untouched. That is why the example resolves 'Could not check availability — try again' on a genuine network fault rather than re-throwing: a fetch timeout is still, from the form’s perspective, a field that has not been proven valid. The one rejection we deliberately swallow is AbortError, because a superseded request has no opinion to contribute — its resolution belongs to the keystroke that replaced it.

This also explains the resolve(true) on success. RHF’s boolean-or-string convention means true (or undefined) clears the error and false produces a generic message, but a string both fails the field and supplies the copy. Returning the string directly keeps the message co-located with the rule that produced it, which is far easier to audit than a separate lookup table of error codes.

Caching Resolved Lookups to Suppress Redundant Requests

Debouncing collapses bursts of keystrokes, but it does nothing for the user who deletes a character and retypes it, or who blurs and refocuses the field. Each of those re-runs the validator against a value the server has already judged. A small per-instance cache eliminates that traffic and makes the field feel instantaneous on repeat values. The key insight is to memoize the resolved outcome keyed by the trimmed, normalized value, and to bypass both the debounce and the fetch on a cache hit.

type Outcome = true | string;

function createCachedUsernameValidator(delayMs = 400) {
  const cache = new Map<string, Outcome>();
  let timer: ReturnType<typeof setTimeout> | null = null;
  let controller: AbortController | null = null;

  return (raw: string): Promise<Outcome> => {
    const value = raw.trim().toLowerCase(); // normalize so "Alice"/"alice" share a slot

    if (value.length < 3) return Promise.resolve('At least 3 characters');
    if (cache.has(value)) return Promise.resolve(cache.get(value)!); // instant on repeats

    return new Promise<Outcome>((resolve) => {
      if (timer) clearTimeout(timer);
      controller?.abort();
      controller = new AbortController();

      timer = setTimeout(async () => {
        try {
          const res = await fetch(
            `/api/username-available?u=${encodeURIComponent(value)}`,
            { signal: controller!.signal },
          );
          const { available } = (await res.json()) as { available: boolean };
          const outcome: Outcome = available ? true : 'That username is taken';
          cache.set(value, outcome); // only cache authoritative answers
          resolve(outcome);
        } catch (err) {
          if ((err as Error).name === 'AbortError') return;
          resolve('Could not check availability — try again'); // never cache a transient fault
        }
      }, delayMs);
    });
  };
}

Two decisions matter here. First, only authoritative answers enter the cache — a network fault resolves an error string but is never stored, so a retry actually re-hits the server instead of replaying the failure forever. Second, the cache is scoped to the validator instance (created once via useMemo), so it is discarded on unmount and never leaks a “taken” verdict across users on a shared machine. If availability can change server-side within a session — someone else claims the name while the form is open — bound the cache with a short TTL or clear it in the form’s onSubmit failure branch rather than trusting it as a source of truth.

Programmatic Revalidation with trigger

mode: 'onChange' fires the validator as the user types, but some flows need to re-run it on demand: after a “check again” button, or when a different field changes the meaning of this one. RHF exposes trigger, which returns a promise resolving to the field’s validity and integrates cleanly with the async rule. Because trigger awaits the same validate promise, it also flips isValidating, so your pending UI keeps working without extra wiring.

const { trigger, formState: { isValidating } } = useForm<Values>({ mode: 'onChange' });

// e.g. a manual retry after a transient failure
async function recheck() {
  const ok = await trigger('username'); // re-invokes the async validator
  if (ok) proceed();
}

Prefer trigger('username') over trigger() with no argument: validating a single field skips the other rules and avoids kicking off unrelated network work. Note that trigger respects the same debounce timer inside the factory, so a rapid button-mash still collapses into one request — the cancellation and caching logic apply uniformly whether the run was triggered by a keystroke or by code.

Announcing the Pending State to Assistive Technology

The example routes both the “Checking availability…” spinner text and the final error through a single role="alert" node with aria-live="polite". That is deliberate. A screen reader user gets no visual spinner, so the transition into and out of the pending state must be spoken. Using polite (not assertive) means the announcement waits for a pause in the user’s typing rather than interrupting mid-word, which matches the low urgency of an availability check. Keep the message container mounted at all times — swapping its text content is announced, but conditionally rendering the whole node in and out can be missed by some AT. Finally, pair the live region with aria-invalid on the input itself so the field’s validity is exposed through the accessibility tree independently of the transient announcement, giving users who navigate by form controls a durable signal after the polite message has scrolled past.

Frequently Asked Questions

How long should the debounce be?

300–500ms is the usual range. Shorter feels instant but multiplies requests; longer feels laggy. 400ms is a safe default — pair it with AbortController so any request that does fire early is cancelled by the next keystroke.

Why disable the submit button while isValidating?

Otherwise a user can submit before the availability check resolves and pass validation against stale state. Disabling on isValidating blocks that race; always re-validate on the server at submit time as the authoritative gate.

Can I share one schema and still do async field checks?

Yes. Keep synchronous rules in the Zod schema via the resolver and add the async validate on register for the network check — RHF merges both. See Integrating the Zod Resolver with React Hook Form for the schema half.

← Back to React Hook Form Validation