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.

Debounced async validation while typing A timeline of keystrokes in a username field showing the sync validator on every keystroke, the async validator starting only after a 400 ms pause, an earlier async run aborted by further typing, and the final verdict. Keystrokes a d a _ l Sync onChange ok? ok ok Async run run 1 aborted run 2 Verdict available 0 ms 400 ms 800 ms 1200 ms 1600 ms 2000 ms 2400 ms
Sync rules run on every keystroke; the async rule waits for a pause, and any newer keystroke aborts the run in flight.

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 lifecycle in TanStack Form A change runs the sync validator, which passes; after the debounce the async validator runs with an abort signal, isValidating becomes true, the server responds, and the field meta updates with the verdict. Field TanStack Form Server Status region handleChange("ada_l") onChange passes → wait 400 ms GET available?u=ada_l (signal) isValidating → "Checking availability…" { available: true } "ada_l is available."
The library orders, debounces and cancels the runs; the component turns isValidating and errors into perceivable status.

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" />}
/>
onChangeAsync versus onBlurAsync Two columns comparing async validators that run after typing pauses with async validators that run when the field loses focus. onChangeAsync + debounce • runs after typing pauses ✓ answer arrives while the user is still on the field ✗ more requests, needs rate limiting • best for availability checks onBlurAsync • runs once when leaving the field ✓ fewer requests ✗ result arrives after the user moved on • best for costly verification lookups
Choose onChangeAsync for cheap checks users want answered while typing, and onBlurAsync for expensive or rate-limited lookups.

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.

← Back to TanStack Form Validation