Formik Field-Level Validation

When should a Formik form validate a field with its own validate function instead of the form’s Yup validationSchema — and how do you make a field-level async check (username availability, coupon validity) that does not fire on every keystroke, does not let an old response overwrite a newer one, and does not steal focus? Formik runs field-level validators alongside the schema and merges their results into the same errors map, which makes them the right tool for rules that need their own timing or context. This recipe builds a debounced, cancellable availability check as a field-level validator, adds a dependent-field rule, and renders every error accessibly, with focus on submit handled the same way the native Constraint Validation API would handle it.

When to Use Field-Level Validation in Formik

Keep most rules in the Yup schema — it is declarative, testable and shareable. Reach for field-level validate when a rule:

  • Is asynchronous and expensive, such as a server lookup that should run on blur, not with every schema pass.
  • Needs its own caching or debouncing, which a schema cannot express.
  • Belongs to a reusable field component that should validate itself regardless of which form it sits in.
  • Depends on context outside the values, such as the current user or feature flags.

Cross-field rules that only need other values can live in the schema with yup.ref or .test; the dependent rule below uses field-level validation only to show how Formik passes values. The form-level setup is covered in the Formik and Yup validation topic.

Schema rules versus field-level validate Two columns comparing validation rules placed in Formik's Yup validationSchema with rules implemented as a field-level validate function. validationSchema (Yup) • formats, lengths, required • cross-field with yup.ref / test ✓ declarative and shareable ✗ runs as one pass over all fields Field-level validate • async lookups with debounce • caching per value ✓ own timing and context ✗ easy to create races without guards
Keep declarative, synchronous rules in the schema; use field-level validate for async, cached or context-dependent checks.

Minimal Working Field-Level Async Validator

import { Formik, Form, Field, getIn, type FieldProps } from "formik";
import * as yup from "yup";

const schema = yup.object({
  username: yup.string().trim().min(3, "Username must be at least 3 characters.").required("Choose a username."),
  email: yup.string().email("Enter an email address like name@example.com.").required("Enter your email address."),
});

/** Debounced, cancellable, cached availability check usable as a Formik field validator. */
function createAvailabilityValidator(delay = 400) {
  const cache = new Map<string, string>();          // value → error ("" = available)
  let controller: AbortController | undefined;
  let timer: ReturnType<typeof setTimeout> | undefined;

  return (value: string): Promise<string | undefined> => {
    const v = value.trim().toLowerCase();
    if (v.length < 3) return Promise.resolve(undefined);   // schema reports length; skip network
    if (cache.has(v)) return Promise.resolve(cache.get(v) || undefined);
    controller?.abort();
    clearTimeout(timer);
    return new Promise((resolve) => {
      timer = setTimeout(async () => {
        controller = new AbortController();
        try {
          const res = await fetch(`/api/usernames/available?u=${encodeURIComponent(v)}`, { signal: controller.signal });
          const error = (await res.json()).available ? "" : "That username is taken. Try adding a number.";
          cache.set(v, error);
          resolve(error || undefined);
        } catch (e) {
          // Aborted: a newer check supersedes this one; unknown: let the server decide on submit.
          resolve(undefined);
        }
      }, delay);
    });
  };
}

const validateUsername = createAvailabilityValidator();

function TextField({ name, label, validate }: { name: string; label: string; validate?: (v: string) => Promise<string | undefined> | string | undefined }) {
  return (
    <Field name={name} validate={validate}>
      {({ field, form }: FieldProps) => {
        const error = (getIn(form.touched, name) || form.submitCount > 0) && getIn(form.errors, name);
        return (
          <div className="field">
            <label htmlFor={name}>{label}</label>
            <input id={name} {...field} aria-invalid={error ? true : undefined} aria-describedby={`${name}-err`} />
            <p id={`${name}-err`} className="field-error" hidden={!error}>{error || ""}</p>
          </div>
        );
      }}
    </Field>
  );
}

export function SignupForm() {
  return (
    <Formik initialValues={{ username: "", email: "" }} validationSchema={schema}
            validateOnChange={false} onSubmit={submitSignup}>
      {({ isValidating, isSubmitting }) => (
        <Form noValidate aria-busy={isValidating || isSubmitting}>
          <TextField name="username" label="Username" validate={validateUsername} />
          <TextField name="email" label="Email address" />
          <button type="submit" aria-disabled={isSubmitting}>Create account</button>
        </Form>
      )}
    </Formik>
  );
}

Formik awaits field-level validators on submit, so a pending availability check is resolved before onSubmit runs — the submit cannot slip through with an unchecked username. With validateOnChange: false, the check runs on blur and on submit rather than on every keystroke, and the cache means a user who tabs back and forth does not trigger repeated requests.

Field-level async validation in Formik On blur Formik calls the schema and the field validator; the validator debounces, aborts older requests, checks the cache, calls the API and resolves with an error, which Formik merges into errors. Formik Yup schema Field validator API validate values (blur) validate(username) debounce 400 ms, abort previous, check cache GET /usernames/available?u=ada { available: false } "That username is taken…"
The schema and the field validator run together; the field validator owns its own debounce, cancellation and cache.

Field-Level Validator Option Reference

Option Type Default Purpose
validate on <Field> (value) => string | undefined | Promise none Per-field rule merged into errors
Debounce number (ms) 400 Wait before calling the API
Cache Map<value, error> per validator Avoid repeat requests for the same value
Abort AbortController per call Cancel superseded requests
Failure handling resolve undefined fail open Server re-checks on submit
validateOnChange form option false here Keep async checks off the keystroke path
isValidating form state Drives aria-busy while checks run

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 { expect, it, beforeAll, afterAll } from "vitest";

const server = setupServer(http.get("/api/usernames/available", () => HttpResponse.json({ available: false })));
beforeAll(() => server.listen());
afterAll(() => server.close());

it("shows the availability error after blur", async () => {
  render(<SignupForm />);
  await userEvent.type(screen.getByLabelText("Username"), "ada_l");
  await userEvent.tab();
  expect(await screen.findByText("That username is taken. Try adding a number.")).toBeVisible();
  expect(screen.getByLabelText("Username")).toHaveAttribute("aria-invalid", "true");
});

Edge Cases and Failure Modes

Races without cancellation. A field validator that simply awaits fetch can resolve out of order: the response for “ada” arrives after the one for “ada_lovelace” and overwrites it. The abort plus debounce in the validator prevents it; a value comparison before resolving is an extra safeguard.

Validators running on every keystroke. With validateOnChange: true (the default), Formik calls field validators on every change. For async validators that means a request per keystroke unless the validator debounces internally — another reason the debounce lives inside it.

Validators recreated on every render. Creating the validator inside the component body gives each render a new debounce timer and an empty cache. Create it once — at module level or with useMemo — so its state survives re-renders.

Returning "" versus undefined. Formik treats an empty string as “no error”, but some helper code checks truthiness differently. Return undefined for valid values to be safe.

Focus stealing when the check completes. Do not call .focus() when an async verdict arrives; the user may already be typing in another field. Let the submit flow move focus, as explained in restoring focus after async validation.

Showing a Pending State Accessibly

A field-level async validator takes time, and users need to know something is happening. Formik exposes isValidating for the whole form, which is too coarse for one field; track pending state per field inside the validator factory (for example, a small useState in the field component set before the fetch and cleared after) and render “Checking availability…” in a status element referenced by the input’s aria-describedby. Keep the input enabled and focused while checking — disabling it blurs it — and announce the final verdict once through a polite live region only if the user has moved to another field. The same pending and announcement rules are described in restoring focus after async validation; Formik changes nothing about them except where the pending flag lives.

Dependent Fields at the Field Level

Field-level validators receive only the field’s value, but Formik’s <Field validate> can close over form values through the render prop or a custom hook, which lets a rule depend on another field. A common example: a “state or province” field required only for some countries. Declare it in the schema with yup.string().when("country", …) where possible; when the rule depends on data fetched at runtime (the list of countries that require a state, loaded from an API), a field-level validator that reads that data is clearer. Remember to re-validate the dependent field when its dependency changes, with validateField("state") in the country field’s change handler, or the dependent error stays stale until the user touches the field again.

function StateField({ countriesRequiringState }: { countriesRequiringState: Set<string> }) {
  const { values, validateField } = useFormikContext<{ country: string; state: string }>();
  useEffect(() => { void validateField("state"); }, [values.country]);        // re-check when country changes
  return (
    <TextField name="state" label="State, province or region"
      validate={(v) => (countriesRequiringState.has(values.country) && !v ? "Enter a state, province or region." : undefined)} />
  );
}
Re-validating a dependent field Changing the country updates Formik values, an effect calls validateField for the state field, whose validator reads the current country and returns or clears its error. Country changes values.country updated Effect validateField( "state") State validator reads values.country errors.state set or cleared Render message + aria-invalid
Dependent fields must be re-validated when their dependency changes, or their error stays stale until touched.

Frequently Asked Questions

When should I use field-level validate instead of validationSchema in Formik?

Keep declarative, synchronous rules in the Yup schema. Use a field-level validate function for asynchronous checks that need their own debounce, caching or cancellation, or for rules that depend on runtime context outside the form values.

How do I stop a Formik async validator from firing on every keystroke?

Set validateOnChange to false so validation runs on blur and submit, and debounce inside the validator itself so any remaining calls are coalesced.

How do I prevent stale async results in Formik?

Abort the previous request with an AbortController when a new validation starts, and compare the value before resolving. Cache results per value to avoid repeated requests.

Does Formik wait for async field validators before submitting?

Yes. On submit, Formik runs all validators, awaits their promises and only calls onSubmit if no errors remain.

← Back to Formik and Yup Validation