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.
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
- DevTools Network. Type quickly into the field with the Network panel open. You should see superseded requests show as “(canceled)” — confirming the
AbortControllerfires — and only the final keystroke’s request complete. - Pending state. Confirm the submit button is disabled while
isValidatingistrue, so a submission cannot race ahead of an unresolved check. - 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.
Related Guides
- React Hook Form Validation — the lifecycle the async validator plugs into
- Integrating the Zod Resolver with React Hook Form — pairing sync schema rules with this async check
- Asynchronous Server Checks — the state machine and UX behind async validation
- Cancelling Stale Async Validation with AbortController — the cancellation primitive in depth