Validation
Pick a validation mode, drop in a schema, and the shipped fields gate the error UI to match
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 split sync/async slots, debounce knobs, or any mix the schema modes don't cover.
The two are mutually exclusive at the type level — useForm({ schema, validators: { onChange } })
is a TS error.
const form = useForm({
schema,
validators: "blur-then-change", // optional, this is the default
onSubmit: ({ value }) => signIn(value),
})TL;DR
| 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
What the shipped fields are tuned for and what most CRUD forms want. Pristine fields stay quiet, errors land on first blur, then update live as the user corrects:
| Stage | Behavior |
|---|---|
| Pristine, user is typing | No error UI |
| User blurs the field | Schema runs; if invalid, the error appears |
| 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, live updates kick in |
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 placeholder="you@zeno.dev" />
<PasswordField />
</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"
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" />
</FieldGroup>
<ResetButton />
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}When the form values are the output
Search bars, filter panels, computed previews. Anything where there's no
submit step and the values themselves drive a side-effect. Drop the
schema from useForm and validate manually inside listeners.onChange:
- 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"
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 entirely. The schema runs once on
submit and errors appear for every invalid field. Useful when each field
is cheap and independent and blur-validating each one would feel chatty
(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 directly: 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 very first character,
even though the form is in the default 'blur-then-change' mode.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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="Resolve"
/>
<EmailField placeholder="you@zeno.dev" />
<InputField
description={`Try "admin" or "api" to see the per-field check.`}
label="Slug"
name="slug"
placeholder="resolve"
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 independently. Both contribute to the field's
error list and clear independently as they pass.
For an async server-side check (uniqueness against the database), swap to
onChangeAsync and use onChangeAsyncDebounceMs to throttle the
request.
Required-field indicator
Every shipped field appends a * to its label when the schema treats
that field as required. On by default. Pass requiredIndicator: false
to useForm to opt out form-wide, or override per field with the
boolean required prop.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 & schema defaults flow from the schema
schema,
// requiredIndicator: false, // disable for the whole form
})
const { CheckboxField, EmailField, PasswordField, SelectField } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField placeholder="you@zeno.dev" />
<PasswordField />
<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>
)
}Fields wrapped in .optional(), .nullable(), or .default(...) stay
silent; everything else gets the *. Async schemas can't be detected
this way, 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} />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 / aria-invalid styling but skip rendering the
inline error. Then subscribe via form.Subscribe and render the summary
yourself.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 placeholder="you@zeno.dev" />
<PasswordField />
</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 pristine fields never appear in
the summary and the form starts quiet. role="alert" and
aria-live="polite" make the summary readable by screen readers as it
updates.
Manual path: validators as an object
The schema path's validators: <mode> is sugar over TanStack Form's
validators + 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, … }). Useful for split sync/async slots, debounce knobs, or per-cause schemas. A schema variable can still be used inside any slot (onChange: schema) — what TS forbids is theschemaprop coexisting with the object form.validationLogic: only accepted in the manual path. The function that decides which validator runs for each event.blurThenChangeLogicis exported from@zeno-lib/formsif 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 thing. 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 pre-filled defaults legitimately violate the schema.