Validation
Choose when your schema runs and when the shipped fields show its errors
useForm has two validation paths, selected by what you pass to
validators:
- Schema path: pass
schema(any Standard Schema: Zod, Valibot, ArkType, …).validatorsis then an optionalValidationModestring controlling when the schema fires:'blur-then-change'(default),'change','blur', or'submit'. - Manual path: omit
schemaand passvalidatorsas TanStack Form's native per-cause object ({ onChange, onBlur, onSubmit, … }). Use this when you need separate sync and async slots, debounce settings, or any mix the schema modes don't cover.
The two are mutually exclusive at the type level: useForm({ schema, validators: { onChange } })
is a TypeScript error.
const form = useForm({
schema,
validators: "blur-then-change", // optional, this is the default
onSubmit: ({ value }) => signIn(value),
})Summary
| Mode | When errors appear |
|---|---|
'blur-then-change' (default) | After first blur, then live as the user corrects |
'change' | As soon as the user types |
'blur' | After blur only, no live re-validation |
'submit' | After submit only |
Per-field validators layer extra checks (server uniqueness, soft
warnings) on top of the form-level schema.
The default: blur-then-change
The shipped fields are built for this mode. A pristine field shows no error, errors appear on the first blur, and they then update live as the user corrects the value:
| Stage | Behavior |
|---|---|
| Pristine, user is typing | No error UI |
| User blurs the field | Schema runs; if invalid, the error appears. For SelectField and DatePickerField, closing the popup counts as the blur; opening it doesn't. |
| User edits to fix it | Schema runs on every keystroke; error clears the moment the value passes |
| User edits a valid field into invalid | Error reappears immediately |
| User submits without ever blurring a required field | Errors appear for every invalid field, then update live |
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
})
export function SignIn() {
const form = useForm({
onSubmit: ({ value }) => signIn(value),
schema,
})
const { EmailField, PasswordField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField name="email" placeholder="you@zeno.dev" />
<PasswordField name="password" />
</FieldGroup>
<ResetButton />
<SubmitButton>Sign in</SubmitButton>
</Form>
</FormProvider>
)
}validators: 'change' for live feedback
Use 'change' when the user wants feedback on every keystroke (password
strength meters, character counters, anything where waiting for a blur
feels sluggish). The schema runs on every change and errors appear as
soon as the field is dirty.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
password: z
.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Include at least one uppercase letter")
.regex(/\d/, "Include at least one digit"),
})
export function ChangePassword() {
const form = useForm({
onSubmit: ({ value }) => savePassword(value.password),
schema,
validators: "change",
})
const { PasswordField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<PasswordField autoComplete="new-password" label="New password" name="password" />
</FieldGroup>
<ResetButton />
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}When the form values are the output
Search bars, filter panels, and computed previews have no submit step, and
the values themselves drive a side-effect. For these, drop the schema from
useForm and validate inside listeners.onChange yourself:
- Authentication package
- Forms package
- Supabase package
- UI primitives
- Documentation site
- End-to-end tests
- Tailwind config
- TypeScript config
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
query: z.string().min(2, "Type at least 2 characters"),
})
export function LiveSearch({
onQueryChange,
}: { onQueryChange: (q: string) => void }) {
const form = useForm({
defaultValues: { query: "" },
listeners: {
onChange: ({ formApi }) => {
const result = schema.safeParse(formApi.state.values)
if (result.success) {
onQueryChange(formApi.state.values.query)
}
},
},
})
const { InputField } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField
label="Search"
name="query"
placeholder="Type to filter…"
/>
</FieldGroup>
</Form>
</FormProvider>
)
}The listener runs the schema directly and gates the side-effect on validity. A one-character query never triggers a search.
validators: 'blur' and 'submit'
'blur' validates only when a field loses focus and doesn't re-validate
on subsequent edits. A user who fixed a typo still sees the old error
until they blur the field again, so 'blur-then-change' is usually the
better pick.
'submit' skips field-level validation. The schema runs once on submit
and errors appear for every invalid field. Use it when each field is cheap
and independent and validating each one on blur would show errors while
the user is still filling the form, as on long settings pages.
const form = useForm({
defaultValues,
schema,
validators: "submit",
})Per-field validators
When a single field needs an extra check on top of the form-level schema,
pass a schema (or async function) to its validators prop. The cause you
pick (onChange, onBlur, onChangeAsync, …) runs on its own event. The
form's validators mode does not gate per-field validators.
In the example below, validators={{ onChange: slugAvailability }} runs
the reserved-name check on every keystroke from the first character, even
though the form is in the default 'blur-then-change' mode.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const slugRegex = /^[a-z0-9-]+$/
const formSchema = z.object({
email: z.email("Enter a valid email"),
name: z.string().min(2, "At least 2 characters"),
slug: z
.string()
.min(3, "At least 3 characters")
.regex(slugRegex, "Lowercase letters, numbers, and dashes only"),
})
const RESERVED_SLUGS = new Set(["admin", "api", "app", "billing", "settings"])
const slugAvailability = z
.string()
.refine((value) => !RESERVED_SLUGS.has(value), {
error: "That slug is reserved, pick another",
})
export function CreateWorkspace() {
const form = useForm({
onSubmit: ({ value }) => createWorkspace(value),
schema: formSchema,
})
const { EmailField, InputField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField
label="Workspace name"
name="name"
placeholder="Acme"
/>
<EmailField name="email" placeholder="you@zeno.dev" />
<InputField
description={`Try "admin" or "api" to see the per-field check.`}
label="Slug"
name="slug"
placeholder="acme"
validators={{ onChange: slugAvailability }}
/>
</FieldGroup>
<ResetButton />
<SubmitButton>Create workspace</SubmitButton>
</Form>
</FormProvider>
)
}The form-level schema handles required-ness and format with the default
'blur-then-change' timing. The slug's per-field onChange validator
runs on every keystroke, regardless of that timing. Both add to the field's
error list, and each error clears when its check passes.
For an async server-side check (uniqueness against the database), swap to
onChangeAsync and set onChangeAsyncDebounceMs so the request waits for
the user to stop typing.
For errors the server only reports on submit (a Next.js server action
rejecting the payload), return them from the action and apply them with
submitAction; see
Server actions.
Required-field indicator
Every shipped field appends a * to its label when the schema treats
that field as required. The indicator is on by default. Pass
requiredIndicator: false to useForm to opt out form-wide, or override
per field with the boolean required prop.
Required-ness also shapes the control. A required field gets
aria-required, a ComboboxField hides its clear button, and a
DatePickerField keeps its date when the selected day is picked again.
requiredIndicator: false hides only the *; the required prop sets all
of it.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { SelectItem } from "@zeno-lib/ui/select"
import { z } from "zod"
const schema = z.object({
email: z.email("Enter a valid email"), // required → *
password: z.string().min(8, "At least 8…"), // required → *
role: z.string().default("member"), // defaulted, no *
newsletter: z.boolean().optional(), // optional, no *
})
export function SignUp() {
const form = useForm({
defaultValues: { newsletter: false }, // strings and schema defaults come from the schema
schema,
// requiredIndicator: false, // disable for the whole form
})
const { CheckboxField, EmailField, PasswordField, SelectField } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField name="email" placeholder="you@zeno.dev" />
<PasswordField name="password" />
<SelectField label="Role" name="role" placeholder="Pick a role">
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectField>
<CheckboxField label="Subscribe to newsletter" name="newsletter" />
</FieldGroup>
</Form>
</FormProvider>
)
}A field gets no * when its schema accepts an
empty value: null (.nullable()), undefined
(.optional(), .default(...)), or the "" or [] a bare z.string() or
z.array() starts at. Everything else gets one. Detection also covers required children of
nested objects and array rows: members[0].name, members[3].name, … all
get the * when the row schema requires name. Detection skips async
schemas, so pass required on the field to set it explicitly:
{/* Force on, regardless of schema */}
<InputField label="Slug" name="slug" required />
{/* Force off, even if schema marks it required */}
<InputField label="Notes" name="notes" required={false} />Empty values
A field's empty value is the first one its schema accepts of null,
undefined, and the type's own empty value ("" for a string, [] for an
array):
| Schema | Empty value |
|---|---|
z.string() | "" |
z.string().optional() | undefined |
z.uuid().nullable(), z.number().nullable() | null |
z.array(z.string()) | [] |
z.string().min(1), z.number() | none, so the field is required |
Clearing a field (the combobox clear button, an emptied number, money,
percentage or year input, an unpicked date) stores its empty value, so the
form stays valid. A field also starts at it when the schema rejects "" or
[] (z.email().optional() starts at undefined, z.uuid().nullable() at
null), so an untouched optional field passes. Text inputs keep "", which
the user typed. A custom field reads the value with getEmptyValue(field, fallback) from @zeno-lib/forms/lib/empty-value.
Where the error message lives
By default the shipped fields render their error inline, right under the input. For longer forms or accessibility-driven designs, you may want to collect every error in one place (above or below the submit button) and keep the fields visually clean.
Pass hideFieldErrors: true to useForm. The shipped fields keep
their data-invalid and aria-invalid styling but don't render the
inline error. Then subscribe with form.Subscribe and render the summary
yourself.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
})
export function SignInWithSummary() {
const form = useForm({
hideFieldErrors: true,
onSubmit: ({ value }) => signIn(value),
schema,
})
const { EmailField, PasswordField, ResetButton, SubmitButton, Subscribe } =
form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField name="email" placeholder="you@zeno.dev" />
<PasswordField name="password" />
</FieldGroup>
<Subscribe
selector={(state) =>
[state.fieldMeta, state.submissionAttempts > 0] as const
}
>
{([fieldMeta, wasSubmitted]) => {
const items = Object.entries(fieldMeta).flatMap(([name, meta]) =>
meta && (meta.isBlurred || wasSubmitted) && !meta.isValid
? meta.errors.map((error: unknown) => ({
field: name,
message:
typeof error === "string"
? error
: (error as { message?: string })?.message ?? "Invalid",
}))
: []
)
if (items.length === 0) {
return null
}
return (
<ul
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm"
role="alert"
>
{items.map((item, i) => (
<li key={i}>
<strong>{item.field}:</strong> {item.message}
</li>
))}
</ul>
)
}}
</Subscribe>
<ResetButton />
<SubmitButton>Sign in</SubmitButton>
</Form>
</FormProvider>
)
}The isBlurred || wasSubmitted gate matches the shipped fields' own
behavior under 'blur-then-change', so the summary lists a field only once
the user blurs it or submits the form. role="alert" and
aria-live="polite" make screen readers announce the summary as it
updates.
Manual path: validators as an object
The schema path's validators: <mode> is sugar over TanStack Form's
validators and validationLogic. Drop schema and pass validators as
the native object when you need finer control:
validators: the original per-cause object ({ onMount, onChange, onBlur, onSubmit, onChangeAsync, … }). Use it for separate sync and async slots, debounce settings, or per-cause schemas. You can still use a schema variable inside any slot (onChange: schema); TypeScript only forbids theschemaprop alongside the object form.validationLogic: the function that decides which validator runs for each event, accepted only in the manual path.@zeno-lib/formsexportsblurThenChangeLogicif you want the default mode's behavior; otherwise pass TanStack'sdefaultValidationLogic.
import { defaultValidationLogic } from "@tanstack/react-form"
// Async uniqueness on blur, sync schema on every keystroke.
const form = useForm({
defaultValues,
validators: {
onChange: schema,
onBlurAsync: async ({ value }) => checkUnique(value.slug),
onBlurAsyncDebounceMs: 300,
},
validationLogic: defaultValidationLogic,
})Anti-patterns
- Don't use
'change'with an expensive sync schema. Every keystroke re-runs the whole schema. For network checks, switch to a per-fieldonChangeAsyncwithonChangeAsyncDebounceMs. - Don't validate on
onMountto "show what's required." The shipped fields already render a*next to required-field labels (see Required-field indicator).onMountis for cases where you expect pre-filled defaults to violate the schema.