ZenoZENO

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, …). validators is then an optional ValidationMode string controlling when the schema fires: 'blur-then-change' (default), 'change', 'blur', or 'submit'.
  • Manual path: omit schema and pass validators as 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

ModeWhen 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:

StageBehavior
Pristine, user is typingNo error UI
User blurs the fieldSchema 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 itSchema runs on every keystroke; error clears the moment the value passes
User edits a valid field into invalidError reappears immediately
User submits without ever blurring a required fieldErrors appear for every invalid field, then update live
Sign in
Type, leave the field, then come back to fix the error — the message updates as you type.
"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.

Pick a password
Errors update on every keystroke — useful for live feedback.
"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.

Create workspace
Slug also runs a reserved-name check via a per-field validator.

Try "admin" or "api" to see the per-field check.

"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.

Sign up
Required fields show a `*`. Optional and defaulted fields stay quiet.
"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):

SchemaEmpty 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.

Sign in
Errors collected at the bottom — fields stay clean.
"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 the schema prop alongside the object form.
  • validationLogic: the function that decides which validator runs for each event, accepted only in the manual path. @zeno-lib/forms exports blurThenChangeLogic if you want the default mode's behavior; otherwise pass TanStack's defaultValidationLogic.
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-field onChangeAsync with onChangeAsyncDebounceMs.
  • Don't validate on onMount to "show what's required." The shipped fields already render a * next to required-field labels (see Required-field indicator). onMount is for cases where you expect pre-filled defaults to violate the schema.