Validation
Schema validation with Zod plus per-field sync and async validators, resolved through the i18n error-map convention.
The form builder validates on two layers that run together:
- A Zod schema passed to
useFormBuilder. This is the source of truth for the shape and constraints of your data. Every issue it raises is mapped to a message through the i18n error-map convention. - Per-field validators declared on a field config — a synchronous
validateand/or an asynchronousvalidateAsync. These cover checks the schema can't express cleanly: cross-field rules, server-side uniqueness, and anything that needs the live form values or a network round-trip.
Both layers feed the same per-field meta.errors array and resolve their messages through the same convention chain, so a Zod issue and a custom validator error are indistinguishable to the UI.
Error strings never live in the schema or the field config. Schemas and validators return codes; the actual text lives in the i18n dictionary, keyed by convention. This keeps messages translatable and out of your component code.
The Zod schema
The schema is the hard contract. Hidden, conditional, and array fields are all still validated by it on submit — visibility and required are UI hints only.
const schema = z.object({
name: z.string().min(1),
note: z.string(),
});
const fields: FormFields<typeof schema> = {
name: {type: "text", label: "form.name"},
note: {type: "textarea", label: "form.note", rows: 2},
};
const form = useFormBuilder({
formId: "playground-submission",
schema,
fields,
defaultValues: {name: "", note: ""},
onSubmit: async (values) => {
/* values is typed z.infer<typeof schema> */
},
});Note that min(1) carries no message string. The form builder resolves the message from the too_small code via the error map. If you want a literal inline message you can still pass one to Zod, but the convention is to leave it to i18n.
A field made conditional with a visible predicate is unmounted when hidden, but its stored value is retained and is still validated on submit. A z.string().min(1) field that is currently hidden with an empty value will fail submit-time validation with no visible affordance to show the error — the submit button appears inert. Make the schema branch tolerate the hidden state: use .optional() / .nullable(), or model the branches with z.discriminatedUnion.
Per-field validators
Declare validate and validateAsync directly on any field config. The exact signatures live on FieldConfigBase:
| Prop | Signature | Runs on | Purpose |
|---|---|---|---|
validate | (value, allValues) => string | undefined | every keystroke (onChange slot) | sync checks; cross-field rules |
validateAsync | (value, allValues, signal) => Promise<string | undefined> | blur (onBlurAsync slot) | server round-trips; uniqueness |
validateAsyncDebounceMs | number | — | debounce for validateAsync. Defaults to 300. |
Both validators receive value plus the whole allValues object (typed Record<string, unknown>, narrow at the call site to read siblings). validateAsync additionally receives an AbortSignal. Both return undefined for valid, or a string code that is resolved through the error-map chain.
const fields: FormFields<typeof schema> = {
confirmEmail: {
type: "text",
label: "form.confirmEmail",
validate: (value, allValues) =>
value === allValues.email ? undefined : "email_mismatch",
},
};A field with no validate / validateAsync produces an empty validators object — a no-op. The per-field validator path costs nothing for the vast majority of fields that only need the Zod schema.
Async validators and the AbortSignal
Pass the signal to fetch (or any cancellable primitive) so an in-flight check is cancelled when the value changes mid-flight or the field unmounts. This is the async validator from the playground's submission-states section:
const fields: FormFields<typeof schema> = {
email: {
type: "text",
label: "form.email",
placeholder: "Try taken@example.com",
validateAsync: async (value, _allValues, signal) => {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, 1_000);
signal.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new Error("aborted"));
});
});
return typeof value === "string" &&
value.trim().toLowerCase() === "taken@example.com"
? "duplicate"
: undefined;
},
validateAsyncDebounceMs: 0,
},
};This demo sets validateAsyncDebounceMs: 0 so the simulated round-trip fires immediately on blur. The real default is 300 ms — leave it unset for production fields so a rate-limited API isn't hammered.
While an async validator runs, field.meta.isValidating is true and the text, textarea, number, and currency renderers show a spinner addon inside their input group. Other renderers run async validation correctly but show no inline spinner.
How errors stack
Field-level sync validation and Zod run together. When both report an error on the same field, the messages accumulate on meta.errors and both can be displayed.
Async validation behaves differently. TanStack's default (asyncAlways: false) skips validateAsync while any sync check still fails — that is, while either the Zod schema or the field's own validate is reporting an error for that field.
Async validator errors do not stack with sync errors. If the Zod schema or the field's validate is failing, validateAsync is bypassed for that cycle — so a "check uniqueness" call only fires once the value is otherwise valid. Don't expect a format error and a "duplicate" error to appear at the same time: fix the format first, then the async check runs on the next blur.
In the email example above: submitting not-an-email fails Zod's format check, so the async duplicate check is skipped entirely (no spinner). Fix the format to taken@example.com, blur, and the async path runs the ~1s round-trip and reports duplicate.
TanStack accumulates errors across each validator slot (onChange, onBlur, onSubmit, onBlurAsync) independently. A single keystroke clears only the onChange slot — an error raised on submit or blur persists until that slot re-runs. A field's error fully clears only after the value passes on every slot that flagged it.
The error-map resolution chain
Every error — Zod issue or custom validator code — is resolved by walking a fixed chain of i18n keys and using the first that exists. The chain is scoped by your formId and the field's path.
For a path a.b.c with code X on a form with formId = "customer":
| # | Key tried | Notes |
|---|---|---|
| 1 | customer.errors.a.b.c.X | scoped, field-specific, code-specific |
| 2 | customer.errors.a.b.c | scoped, field-specific, any code |
| 3 | customer.errors.<wild>.X | index-stripped path, code-specific — only if the path has numeric segments |
| 4 | customer.errors.<wild> | index-stripped path, any code — only if the path has numeric segments |
| 5 | forms.errors.X | generic, code-specific |
| 6 | final fallback | Zod issue: Zod's localized default. Custom validator: the raw code string. |
Steps 3 and 4 are skipped entirely when the path has no numeric segments, keeping the resolver cost-free for the common non-array case. Both createZodErrorMap (Zod issues) and resolveValidatorError (custom validators) walk this exact chain — the only difference is the step-6 fallback.
Missing keys are detected with i18n.exists(), which returns false so resolution falls through to the next level. If a field's namespace hasn't loaded yet, every scoped key misses and the chain degrades gracefully to the Zod default.
Interpolation args come straight from the Zod issue payload (minimum, maximum, expected, …), so a key like forms.errors.too_small can read {{minimum}} in its translation. Zod's internal fields (code, path, message, input, …) are stripped before the rest is passed through.
The wildcard collapse for arrays
Array fields produce row-indexed paths. The wildcard steps (3–4) collapse pure-numeric dotted segments so array-bound forms don't need a key per row. The collapse splits the path on . and drops any segment that is digits-only: a row-level error at contacts.0.value falls back to contacts.value.
This is exactly the shape of a Zod issue path — Zod reports ["contacts", 0, "value"], which the error map joins into the dotted contacts.0.value. The 0 is a standalone numeric segment, so the collapse fires and your dictionary only needs contacts.value.
Field-level validators on array items are different. A validate / validateAsync declared inside an array item config is bound to TanStack's canonical bracket accessor — contacts[0].value — and that exact string is what the error map receives. Splitting contacts[0].value on . yields ["contacts[0]", "value"]; neither segment is digits-only, so the numeric collapse does not fire. A row-level validator error therefore resolves against the literal contacts[0].value key (or falls through to the generic forms.errors.<code> key), not contacts.value. Key your dictionary on the generic code for these, or on the bracket path if you need a row-specific message.
A custom validator example
nif: {
type: "text",
label: "customer.nif",
validateAsync: async (value, _all, signal) => {
const taken = await api.entities.isNifTaken(String(value), {signal});
return taken ? "duplicate" : undefined;
},
},With formId: "customer", the code "duplicate" resolves like this:
customer.errors.nif.duplicate— define this for a field-specific message.customer.errors.nif— any code on this field.forms.errors.duplicate— a generic "already in use" message shared across forms.- The literal string
"duplicate"— what shows if no key matches.
The form-builder playground does not load the remote i18n dictionary, so the async demo above displays the verbatim string "duplicate". That is the step-6 raw-code fallback in action — wire up customer.errors.email.duplicate in your dictionary to show a real message.
Validating state
form.useFormState((s) => s.isValidating) is true while any validator is in flight. It aggregates two TanStack slots that are otherwise separate:
isValidating: Boolean(state.isValidating || state.isFieldsValidating)state.isValidating alone covers only form-level validators; per-field async validators live on state.isFieldsValidating. The form builder ORs them, so the value reflects both. The SubmitButton and SubmitButtonGroup primitives gate on this combined value — the user can't push a save through an as-yet-unresolved async check.
Submit an invalid value. The Zod schema fails; the async check is skipped. The error shows immediately, no spinner.
Fix the format, then blur. Sync now passes, so the async validator fires. isValidating flips to true, the input shows its spinner, and submit is disabled.
The async check resolves. If it returns a code, the resolved message appears and submit stays disabled until the value is valid. If it returns undefined, the field clears and submit re-enables.
The headless form.useField(name) handle exposes meta.isValidating per field, plus error (first resolved error) and errors (all resolved errors) as plain, already-i18n-resolved strings. It does not re-run the error map — resolution happens once, at validation time.