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 400ms (aborted) debounce 400ms → 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.

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