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:
"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.nameprops autocomplete againstdefaultValues, andvaluetypes 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.SubmitButtonplaced 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 viadefaultValues— passing them is the only way to tell the form "this is the empty value I want". - Anything you put in
defaultValuesalways 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.