Formify Docs

Async Validation

Debounced, cancellable async validation and unique checks

Async Validation

Formify makes async validation (API calls, uniqueness checks, server-side format validation) a first-class feature:

  • Debounced — configurable per field or globally
  • Cancellable — stale responses are aborted via AbortSignal and ignored
  • Per-field loading flags — form.validating.username
  • Submit-safe — async validators always complete before onSubmit runs

Basic async validator

const form = useForm({
  initialValues: { username: "" },
  validationSchema: {
    username: {
      required: true,
      asyncValidate: async (value, values, { signal }) => {
        const res = await fetch(`/api/validate-username?value=${value}`, { signal });
        const data = await res.json();
        return data.valid ? undefined : data.message;
      },
      asyncValidateOptions: { debounce: 400 },
    },
  },
  onSubmit: (values) => api.createUser(values),
});
  • The validator receives (value, values, ctx) where ctx.signal aborts when the field changes again or the form unmounts.
  • Return an error message, or undefined/null when valid.
  • debounce waits for the user to pause typing before hitting the API.

uniqueCheck — "is this already taken?"

The most common async check is uniqueness. uniqueCheck wraps a fetcher that returns true when the value is taken:

import { useForm, uniqueCheck } from "formify-js";

const form = useForm({
  initialValues: { username: "" },
  validationSchema: {
    username: {
      required: true,
      minLength: 3,
      asyncValidate: uniqueCheck(
        async (value, { signal }) => {
          const res = await fetch(
            `/api/users/exists?username=${encodeURIComponent(String(value))}`,
            { signal },
          );
          return res.json(); // true = already exists
        },
        { message: "This username is already taken", debounce: 400 },
      ),
    },
  },
  onSubmit: (values) => api.createUser(values),
});

// In the UI:
{form.validating.username && <Spinner size="sm" />}

Cancellation in action

Type "joh", pause, type "john":

  1. The check for "joh" is aborted the moment the value changes.
  2. Its result (even if the network resolves later) is discarded.
  3. Only the result for "john" is applied.

You get this for free — pass ctx.signal to fetch (or any abortable API) to also cancel the network request itself.

Per-field vs global debounce

// Per field:
asyncValidateOptions: { debounce: 500 }

// Globally (all async validators):
useForm({ validateDebounce: 300, ... })

On submit, debounce is bypassed so validation never delays submission.

Handling slow checks

<input {...form.getFieldProps("username")} />
{form.validating.username && (
  <p className="hint">Checking availability…</p>
)}
{form.touched.username && form.errors.username && (
  <p className="error">{form.errors.username}</p>
)}

form.isValidating is also available for a form-wide spinner (it's true while any field is validating).

Error handling

If an async validator throws (non-abort error), the field is treated as valid (the error is logged in development) — a network hiccup should never block the user. For hard requirements, re-check server-side on submit or use a plugin's onSubmit hook (see plugins).

On this page