TanStack Form Async Field Validators
How do you check a username, a coupon or an address against the server from a TanStack Form field — debounced so it does not fire per keystroke, cancelled when the user keeps typing, skipped when the value is not even locally valid, shown to the user as “Checking…” while it runs, and announced accessibly when it finishes? TanStack Form builds most of the plumbing in: onChangeAsync and onBlurAsync validators, asyncDebounceMs, an AbortSignal per run, and isValidating in field meta. This recipe assembles them into a production-ready availability check, adds the accessibility layer the library leaves to you, and makes sure a pending check blocks submission — so the final verdict still reaches users through focus and the field’s description, as with the native Constraint Validation API.
When to Use Async Field Validators
Use an async field validator when a single field’s validity depends on data only the server has, and the user benefits from knowing before they submit:
- Availability — usernames, handles, workspace slugs, domain names.
- Codes — coupons, gift cards, invitation codes.
- Lookups — postcodes to addresses, VAT or company numbers.
Do not use one for rules that can be checked locally (formats, lengths) — put those in a sync onChange or onBlur validator, which also gates the async one. And remember that async client checks are hints: the server must re-check on submit, as covered in rate limiting async validation endpoints. The library’s overall validation model is described in the TanStack Form validation topic.
Minimal Working Async Field Validator
import { useForm, type AnyFieldApi } from "@tanstack/react-form";
type Availability = { available: boolean; suggestion?: string };
async function checkUsername(value: string, signal: AbortSignal): Promise<string | undefined> {
const res = await fetch(`/api/usernames/available?u=${encodeURIComponent(value)}`, { signal });
if (res.status === 429) return undefined; // rate limited: unknown, let server decide
if (!res.ok) return undefined; // fail open on errors
const data = (await res.json()) as Availability;
return data.available ? undefined : `That username is taken.${data.suggestion ? ` Try ${data.suggestion}.` : ""}`;
}
export function UsernameForm() {
const formRef = useRef<HTMLFormElement>(null);
const form = useForm({
defaultValues: { username: "" },
onSubmit: async ({ value }) => createAccount(value),
onSubmitInvalid: () => requestAnimationFrame(() =>
formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()),
});
return (
<form ref={formRef} noValidate onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }}>
<form.Field
name="username"
asyncDebounceMs={400}
validators={{
// Sync rules gate the async one: if these fail, onChangeAsync does not run.
onChange: ({ value }) =>
value.length < 3 ? "Username must be at least 3 characters." :
!/^[a-z0-9._]+$/.test(value) ? "Use lowercase letters, numbers, dots and underscores." : undefined,
onChangeAsync: ({ value, signal }) => checkUsername(value, signal),
}}
children={(field) => <UsernameInput field={field} />}
/>
<form.Subscribe selector={(s) => [s.isSubmitting, s.isValidating] as const}
children={([submitting, validating]) => (
<button type="submit" aria-disabled={submitting}>
{submitting ? "Creating…" : validating ? "Create account (checking…)" : "Create account"}
</button>
)}
/>
</form>
);
}
function UsernameInput({ field }: { field: AnyFieldApi }) {
const { meta } = field.state;
const error = meta.errors.find(Boolean) as string | undefined;
const show = Boolean(error) && (meta.isBlurred || field.form.state.submissionAttempts > 0 || meta.errorMap.onChange === undefined);
const status = meta.isValidating ? "Checking availability…" : !error && field.state.value.length >= 3 && meta.isDirty ? `${field.state.value} is available.` : "";
return (
<div className="field">
<label htmlFor="username">Username</label>
<input id="username" name="username" autoComplete="username" autoCapitalize="none" spellCheck={false}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value.toLowerCase())}
onBlur={field.handleBlur}
aria-invalid={show ? true : undefined}
aria-describedby="username-hint username-status username-err" />
<p id="username-hint" className="hint">3 or more characters: letters, numbers, dots and underscores.</p>
<p id="username-status" className="field-status" aria-live="polite">{status}</p>
<p id="username-err" className="field-error" hidden={!show}>{show ? error : ""}</p>
</div>
);
}
TanStack Form handles the hard parts: it waits asyncDebounceMs after the last change, skips onChangeAsync when onChange returned an error, aborts the previous run’s signal when a new one starts, and awaits pending async validators on submit. The component adds what the library does not: a visible, announced status (“Checking availability…” then “ada_l is available.”), an error linked by aria-describedby, and focus on failed submit. The visibility rule shows async results even before blur — an availability verdict is information the user is waiting for — while sync format errors still wait for blur.
Async Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
validators.onChangeAsync |
({ value, signal }) => Promise<error> |
— | Runs after changes, debounced |
validators.onBlurAsync |
same | — | Runs on blur; use for costly checks |
asyncDebounceMs |
number |
0 |
Delay after the last change |
onChangeAsyncDebounceMs |
number |
inherits | Per-event override |
signal |
AbortSignal |
per run | Aborted when superseded |
| Sync gating | behaviour | on | Async runs only if sync validators pass |
meta.isValidating |
boolean |
— | Drives the “Checking…” status |
asyncAlways |
boolean |
false |
Run async even when sync fails (rarely wanted) |
Verification Steps
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, beforeAll, expect, it } from "vitest";
let calls = 0;
const server = setupServer(http.get("/api/usernames/available", () => { calls++; return HttpResponse.json({ available: false, suggestion: "ada_l2" }); }));
beforeAll(() => server.listen());
afterAll(() => server.close());
it("debounces and reports the verdict", async () => {
render(<UsernameForm />);
await userEvent.type(screen.getByLabelText("Username"), "ada_l", { delay: 50 });
expect(await screen.findByText("That username is taken. Try ada_l2.")).toBeVisible();
expect(calls).toBe(1);
});
Edge Cases and Failure Modes
Race with submit. Without the library awaiting async validators on submit, a fast Enter would submit an unchecked value. TanStack Form awaits them; keep that behaviour by not calling your own submit logic outside form.handleSubmit().
Rate limits and outages. Treat 429 and network errors as “unknown”, not as “taken”: return undefined so the field is not blocked, and let the server re-check on submit. Showing a false “taken” message to a real user because of a rate limit is worse than showing nothing.
Announcement storms. aria-live="polite" on the status element announces every change. Because the status only changes when a check starts and finishes, that is two announcements per pause; if that feels too chatty, move the “Checking…” text out of the live region and announce only the verdict.
Unmounted fields. If a field is removed while its check is in flight (a conditional section closes), the aborted run must not write back into the form. Passing the signal to fetch handles this; avoid side effects after await that ignore the signal.
Case and normalisation. The example lower-cases on change so the check and the stored value agree. If you normalise, do it before validating, and on the server too, or “Ada” and “ada” can both appear available.
Caching Verdicts Between Runs
TanStack Form cancels superseded runs but does not remember results, so a user who types “ada”, deletes a character and retypes it triggers the same lookup twice. A small cache inside the validator — a Map from normalised value to verdict, created outside the component — removes those repeats and makes back-and-forth editing feel instant. Expire entries after a short time (a minute is plenty for availability), because another user can take a name in the meantime, and never cache “unknown” results from rate limits or network errors, or a transient failure becomes permanent for that value. The cache is also a natural place to count how many distinct values a session checks, which is a useful signal for spotting enumeration attempts on the endpoint.
Blur-Time Checks for Expensive Lookups
Some lookups are too expensive or too rate-limited to run while typing — a VAT number check against a government API, a full address verification. Use onBlurAsync instead of onChangeAsync: the check runs once when the user leaves the field, and again on submit if the value changed. Keep sync format validation on onChange or onBlur so obviously malformed values never reach the network, and show “Checking…” in the field’s status while the blur check runs. Because the user has moved on by the time the result arrives, follow the focus rules in restoring focus after async validation: show the result at the field and announce it politely, but do not move focus back.
<form.Field
name="vatNumber"
validators={{
onBlur: ({ value }) => (/^[A-Z]{2}[0-9A-Z]{8,12}$/.test(value) ? undefined : "Enter a VAT number like GB123456789."),
onBlurAsync: async ({ value, signal }) => ((await verifyVat(value, signal)) ? undefined : "We couldn't find that VAT number."),
}}
children={(field) => <TextField field={field} label="VAT number" />}
/>
Frequently Asked Questions
How do I debounce async validation in TanStack Form?
Set asyncDebounceMs on the field (or a per-event variant such as onChangeAsyncDebounceMs). TanStack Form waits that long after the last change before running the async validator.
How are stale async results prevented?
Each async run receives an AbortSignal that is aborted when a newer run starts. Pass the signal to fetch so superseded requests are cancelled and their results never apply.
Does the async validator run if the value is locally invalid?
No. By default, async validators for an event run only if the synchronous validators for that event passed, which keeps malformed values off the network.
How do I show that a check is in progress?
Read field.state.meta.isValidating and render a short "Checking…" status in an element referenced by the input's aria-describedby, keeping the input enabled and focused.
Related Guides
- TanStack Form Validation — the validation model and field components.
- Throttling vs Debouncing Server Validation — choosing the timing strategy.
- Username Validation Rules and Reserved Names — the sync rules that gate the check.
- React Hook Form Async Field Validation — the same problem in React Hook Form.