Zeno

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, …). 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 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

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

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:

StageBehavior
Pristine, user is typingNo error UI
User blurs the fieldSchema runs; if invalid, the error appears
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, live updates kick in
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"
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.

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

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

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

Sign in
Errors collected at the bottom — fields stay clean.
"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 the schema prop coexisting with the object form.
  • validationLogic: only accepted in the manual path. The function that decides which validator runs for each event. blurThenChangeLogic is exported from @zeno-lib/forms 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 thing. 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 pre-filled defaults legitimately violate the schema.