Zeno

Forms

Typed form hook with pre-wired field components, Zod validation, and shadcn-style layout

@zeno-lib/forms is a thin layer over TanStack Form and Zod that ships ready-to-use field components built on @zeno-lib/ui primitives. You get type-safe field names, an automatic submit button, and a single schema prop instead of hand-wiring validators.

Quick start

A newsletter form, validated with Zod:

Subscribe to our newsletter
Monthly digest of new fields, examples, and releases.
"use client"

import { Form, FormProvider, useForm } from "@zeno-lib/forms"
import { z } from "zod"

const schema = z.object({
  email: z.email("Enter a valid email"),
})

export function Newsletter() {
  const form = useForm({
    onSubmit: async ({ value }) => {
      await subscribe(value.email)
    },
    schema,
  })
  const { EmailField, SubmitButton } = form

  return (
    <FormProvider form={form}>
      <Form>
        <EmailField placeholder="you@zeno.dev" />
        <SubmitButton>Subscribe</SubmitButton>
      </Form>
    </FormProvider>
  )
}

Three pieces to know:

  • useForm({ defaultValues, schema, onSubmit }) returns the form with typed field components attached. name props autocomplete against defaultValues, and value types match what your schema produces.
  • <FormProvider form={form}> lets every destructured component (EmailField, SubmitButton, …) resolve the form from React context.
  • <Form> renders the <form> element and wires up submission. SubmitButton placed inside it disables and shows a spinner during submit, no extra wiring needed.

Destructure the components

The field components on form are stable, so destructure them at the top of your component for cleaner JSX:

const form = useForm({ /* … */ })
const { EmailField, PasswordField, SubmitButton } = form

return (
  <FormProvider form={form}>
    <Form>
      <FieldGroup>
        <EmailField />
        <PasswordField />
      </FieldGroup>
      <SubmitButton>Sign in</SubmitButton>
    </Form>
  </FormProvider>
)

The destructure list reads like an import block: a glance tells you which fields the component uses. This is the recommended pattern in @zeno-lib/forms, and it differs from upstream TanStack Form (which favors form.EmailField).

Validation with Zod

Pass any Standard Schema to the schema prop. Zod, Valibot, and ArkType all work:

import { z } from "zod"

const schema = z.object({
  email: z.email(),
  password: z.string().min(8, "At least 8 characters"),
})

const form = useForm({
  schema,
  onSubmit: async ({ value }) => {
    // `value` is typed from the schema
  },
})

A second prop, validation, controls when errors appear: 'blur-then-change' (default), 'change', 'blur', or 'submit'. The default keeps pristine fields quiet, errors land on first blur, then update live. Full breakdown in Validation modes.

Defaults from the schema

When you pass a Zod schema, useForm derives initial values from it so you don't repeat yourself in defaultValues:

  • .default(value) flows through. Change the default in the schema, the form follows.
  • Strings (z.string(), z.email(), z.url(), …) and arrays (z.array(...)) without a .default(...) initialise as "" and [] respectively, so React inputs render controlled.
  • Numbers, booleans, dates, enums, files without a .default(...) carry domain meaning (0? false? today?), so the user supplies them explicitly via defaultValues — passing them is the only way to tell the form "this is the empty value I want".
  • Anything you put in defaultValues always wins on conflict — schema defaults are a baseline, not a guard.
  • Nested z.object(...) recurses with the same rules; partial user overrides are deep-merged into the schema-derived shape.
const schema = z.object({
  email: z.email(),                    // → ""
  password: z.string().min(8),         // → ""
  role: z.string().default("member"),  // → "member"
  newsletter: z.boolean().optional(),  // skipped (boolean) → user supplies
})

const form = useForm({
  defaultValues: { newsletter: false }, // only the boolean needs spelling out
  schema,
})

This Zod-only behaviour degrades gracefully: pass a Valibot or ArkType schema and the hook simply skips extraction, leaving your defaultValues untouched.

Custom fields

When the shipped gallery doesn't cover what you need, AppField and useFieldContext give you the same typed field state with no styling constraints. See Custom fields.

Devtools

Install @tanstack/react-devtools and @tanstack/react-form-devtools (both declared as peer deps), then drop the panel near your app root:

import { TanStackDevtools } from "@tanstack/react-devtools"
import { formDevtoolsPlugin } from "@tanstack/react-form-devtools"

<TanStackDevtools plugins={[formDevtoolsPlugin()]} />

Every useForm instance shows up live, no wrapping needed.