Forms
A typed form factory, a registry field kit, and Zod validation on a shadcn-style layout
@zeno-lib/forms is a typed layer over TanStack Form
and Zod. The headless core (the form factory, validation, and schema
helpers) ships on npm and is UI-free. The shadcn CLI copies the field components (built on
shadcn primitives) into your repo from the registry, so you own and restyle them. You get type-safe
field names, a submit button that disables itself during submit, and a single schema prop
instead of hand-wired validators.
Setup
Add the field kit and the create-form composition with the shadcn CLI, then install the headless
core:
pnpm dlx shadcn@latest add zeno-lib/zeno/create-form
pnpm add @zeno-lib/formscreate-form passes your installed fields to the factory and exports the useForm hook. Import
it from wherever you added it. The examples below import the package's own copy of the same file,
@zeno-lib/forms/create-form.
Quick start
A newsletter form, validated with Zod:
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
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 name="email" 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 callsform.handleSubmit()on submit. ASubmitButtoninside it disables itself and shows a spinner during submit, with no props to pass.
Destructure the components
The field components on form are stable across renders, so destructure
them at the top of your component and write <EmailField> rather than
<form.EmailField>:
const form = useForm({ /* … */ })
const { EmailField, PasswordField, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField name="email" />
<PasswordField name="password" />
</FieldGroup>
<SubmitButton>Sign in</SubmitButton>
</Form>
</FormProvider>
)Like an import block, the destructure line lists every field 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, validators, controls when errors appear:
'blur-then-change' (default), 'change', 'blur', or 'submit'. With
the default, a field shows no error until its first blur, then its errors
update as the user types. See
Validation modes for
the full breakdown.
Defaults from the schema
When you pass a Zod schema, useForm derives initial values from it
so you don't repeat them in defaultValues:
.default(value)becomes the field's initial value, so changing the default in the schema changes the form.- 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(...)are skipped, because their empty value depends on your domain (0,false,today). Supply them indefaultValues, the only way to tell the form which empty value you want. - Anything you put in
defaultValuesalways wins on conflict; the schema only fills the keys you leave out. - Nested
z.object(...)follows the same rules, anduseFormdeep-merges a partial nested object fromdefaultValuesinto 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,
})Schema defaults are Zod-only. With a Valibot or ArkType schema, the hook
skips extraction and uses your defaultValues as they are.
Focus on submit
When a submit fails validation, <Form> moves focus to the first control
with aria-invalid="true" inside the submitted <form> element. This
follows the pattern from TanStack Form's
focus management guide.
Because the lookup only searches the submitted form, a form in a dialog
never moves focus to the page behind it, and a submit from an external
<button form="…"> works the same way. When the invalid element is a
group root that can't take focus itself (radio group, slider), <Form>
focuses its first tabbable child. An onSubmitInvalid you pass goes to
TanStack Form unchanged. Forms rendered without <Form> (your own
<form> calling handleSubmit) don't get this behaviour.
Custom fields
When the field kit doesn't cover what you need, AppField and
useFieldContext give you the same typed field state, and you write the
markup. See
Custom fields.
Devtools
Install @tanstack/react-devtools and @tanstack/react-form-devtools
(@zeno-lib/forms lists both as optional peer dependencies), then render 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 appears in the panel with its live state. You don't need to wrap
anything.