Examples
Working @zeno-lib/forms examples, including card and dialog layouts, array and dynamic fields, server errors and unsaved-changes warnings
The examples start with layout patterns, then cover one feature each. Every preview works: fill it in and submit to see the returned values.
For validation patterns (live forms without actions, per-field validation modes, async server checks), see Validation modes.
Form in a card
Put the form in a <Card>, with the header on top, the fields in
<CardContent> and the actions in <CardFooter>. <FormProvider> wraps
the whole <Card>, so ResetButton and SubmitButton in <CardFooter>
still find the form through context, even though they sit outside the
<form> element. The id on <Form> and form="…" on <SubmitButton>
keep native submission working (Enter to submit, accessibility).
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@zeno-lib/ui/card"
import { Field, FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
name: z.string().min(2, "At least 2 characters"),
})
export function DisplayNameCard() {
const form = useForm({
onSubmit: ({ value }) => saveName(value.name),
schema,
})
const { InputField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Display name</CardTitle>
<CardDescription>How we'll address you in the app.</CardDescription>
</CardHeader>
<CardContent>
<Form id="card-form">
<FieldGroup>
<InputField label="Name" name="name" placeholder="Ada Lovelace" />
</FieldGroup>
</Form>
</CardContent>
<CardFooter>
<Field orientation="horizontal">
<ResetButton />
<SubmitButton form="card-form">Save</SubmitButton>
</Field>
</CardFooter>
</Card>
</FormProvider>
)
}Form in a dialog
Keep the form inside <DialogContent> and close the dialog from
onSubmit once the work is done.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { Button } from "@zeno-lib/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@zeno-lib/ui/dialog"
import { FieldGroup } from "@zeno-lib/ui/field"
import { useState } from "react"
import { z } from "zod"
const schema = z.object({
name: z.string().min(1, "Name is required"),
slug: z
.string()
.min(1, "Slug is required")
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, and dashes only"),
})
export function CreateProjectDialog() {
const [open, setOpen] = useState(false)
const form = useForm({
onSubmit: ({ value }) => {
createProject(value)
setOpen(false)
},
schema,
})
const { InputField, ResetButton, SubmitButton } = form
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger render={<Button>Create project</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>
Give your project a name and a slug to get started.
</DialogDescription>
</DialogHeader>
<FormProvider form={form}>
<Form id="project-form">
<FieldGroup>
<InputField label="Name" name="name" placeholder="Acme admin" />
<InputField
description="Used in URLs."
label="Slug"
name="slug"
placeholder="acme-admin"
/>
</FieldGroup>
</Form>
<DialogFooter className="mt-4">
<Button onClick={() => setOpen(false)} type="button" variant="ghost">
Cancel
</Button>
<ResetButton />
<SubmitButton form="project-form">Create</SubmitButton>
</DialogFooter>
</FormProvider>
</DialogContent>
</Dialog>
)
}Form dialog
For create and edit dialogs, the form-dialog registry item handles the
details the hand-rolled version above leaves out:
- One session per
open(): open it blank, or with a row's values and a field to focus, as indialog.open({ defaultValues: row, focus: "email" }). - Unsaved-changes guard: the dialog asks before discarding changes on
Cancel, ×, Escape or an outside press, and the page warns on unload while
the dialog is open.
"Changed" means the values differ from the session's defaults
(
!form.state.isDefaultValue), so reverting an edit by hand counts as unchanged. The form resets once the close animation ends. - Footer submit: the Save button sits outside the
<form>(it targets it withform="id"), shows a spinner while submitting, and the dialog closes when the submit succeeds (closeOnSubmit, defaulttrue). A failed validation, a thrown error, or aValidationErrorkeeps it open.
pnpm dlx shadcn@latest add zeno-lib/zeno/form-dialog"use client"
import { FormDialog } from "@/components/form/form-dialog"
import { useForm } from "@/components/form/create-form"
import { useFormDialog } from "@zeno-lib/forms/lib/use-form-dialog"
const BLANK = { email: "", name: "" }
export function Members({ members }: { members: Member[] }) {
const dialog = useFormDialog({ defaultValues: BLANK })
const form = useForm({
// Let the dialog own the defaults: see the pitfall below.
defaultValues: dialog.defaultValues,
onSubmit: ({ value }) => saveMember(value),
schema: memberSchema,
})
const { EmailField, InputField } = form
return (
<>
{members.map((member) => (
<Button
key={member.id}
onClick={() => dialog.open({ defaultValues: member, focus: "email" })}
>
Edit {member.name}
</Button>
))}
<FormDialog
dialog={dialog}
form={form}
title="Member"
trigger={<Button>Add member</Button>}
>
<FieldGroup>
<InputField label="Name" name="name" />
<EmailField label="Email" name="email" />
</FieldGroup>
</FormDialog>
</>
)
}FormDialog prop | Type | What it does |
|---|---|---|
dialog | FormDialogController | The controller from useFormDialog(). |
form | the useForm() result | Its onSubmit runs on Save. |
title / description | ReactNode | Dialog header. |
trigger | ReactElement | Opens a session with the hook's defaults. |
submitLabel / cancelLabel | ReactNode | Default to "Save" / "Cancel". |
footer | ReactNode | Extra footer content, before the buttons. |
closeOnSubmit | boolean | Close after a successful submit. Defaults to true. |
guard | boolean | The unsaved-changes prompt and unload warning. Defaults to true. |
discardPrompt | { title?, description?, confirmLabel?, cancelLabel? } | Copy for the discard prompt. |
className / formClassName | string | Classes for the popup / the <form>. |
useFormDialog({ defaultValues? }) returns { isOpen, open, close, defaultValues, focus, session }. close() skips the guard (use it after
an out-of-band save). The same package exports useLeaveGuard for your
own editors: requestLeave(proceed) runs proceed right away when there's
nothing to lose, or holds it until confirmLeave() / cancelLeave().
Pitfall: let the dialog own
defaultValues. TanStack Form ignores newdefaultValuesonce a form is touched, and aform.reset(values)is undone on the next render ifuseFormstill receives the old defaults (the reset leaves the form untouched, so TanStack re-applies its options). Passingdialog.defaultValuestouseFormkeeps both in step, andFormDialogcallsform.reset(defaults)at the start of each session. For the same reason, don'tresetinsideonSubmit; the dialog resets itself after closing.
Simple login
A two-field form with a Zod schema. name autocompletes against
defaultValues.
"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 Login() {
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>
)
}Async submission
SubmitButton disables itself and shows a spinner while onSubmit is in
flight or while any async field validator is running. The only
requirement is that onSubmit returns a Promise.
For per-field async checks, set validators.onChangeAsync (with
onChangeAsyncDebounceMs to debounce it). Put a <ValidationSpinner />
inside any input-style field to render a spinner inline. ResetButton
isn't subscribed to form state by default; wrap it in form.Subscribe
to disable it during the round-trip.
"use client"
import { Form, FormProvider, useForm, ValidationSpinner } 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"),
name: z.string().min(2, "At least 2 characters"),
})
export function AccountSettings() {
const form = useForm({
onSubmit: async ({ value }) => {
await saveAccount(value)
},
schema,
})
const { EmailField, InputField, ResetButton, Subscribe, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField label="Name" name="name" placeholder="Ada Lovelace" />
<EmailField
name="email"
placeholder="you@zeno.dev"
validators={{
onChangeAsync: async ({ value }) => {
const exists = await checkEmailExists(value)
return exists ? "Email already in use" : undefined
},
onChangeAsyncDebounceMs: 500,
}}
>
<ValidationSpinner />
</EmailField>
</FieldGroup>
<Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => <ResetButton disabled={isSubmitting} />}
</Subscribe>
<SubmitButton>Save changes</SubmitButton>
</Form>
</FormProvider>
)
}Sign up with cross-field validation
z.refine runs after the object schema and lets you compare values
across fields. The error attaches to whichever field you point path
at, here the "Confirm password" field.
"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({
confirmPassword: z.string(),
email: z.email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export function Signup() {
const form = useForm({
onSubmit: ({ value }) => createAccount(value),
schema,
})
const { EmailField, PasswordField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<EmailField name="email" placeholder="you@zeno.dev" />
<PasswordField autoComplete="new-password" name="password" />
<PasswordField
autoComplete="new-password"
label="Confirm password"
name="confirmPassword"
/>
</FieldGroup>
<ResetButton />
<SubmitButton>Create account</SubmitButton>
</Form>
</FormProvider>
)
}Array fields
Pass mode="array" on form.Field to manage a list. The render-prop
exposes pushValue and removeValue, and you address each row with
bracketed paths like members[0].name, typed against the schema.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { Button } from "@zeno-lib/ui/button"
import { FieldGroup, FieldLegend, FieldSet } from "@zeno-lib/ui/field"
import { TrashIcon } from "lucide-react"
import { z } from "zod"
const memberSchema = z.object({
email: z.email("Enter a valid email"),
name: z.string().min(1, "Name is required"),
})
const schema = z.object({
members: z.array(memberSchema).min(1, "Add at least one member"),
})
type Member = z.infer<typeof memberSchema>
export function TeamInvites() {
const form = useForm({
defaultValues: { members: [{ email: "", name: "" }] as Member[] },
onSubmit: ({ value }) => inviteMembers(value.members),
schema,
})
// `Field` is TanStack Form's render-prop component; rename to avoid
// clashing with `@zeno-lib/ui/field`'s `Field`.
const {
EmailField,
Field: ArrayField,
InputField,
ResetButton,
SubmitButton,
} = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<ArrayField mode="array" name="members">
{(arrayField) => (
<FieldSet>
<FieldLegend>Members</FieldLegend>
{arrayField.state.value.map((_, index) => (
<div className="flex items-end gap-2" key={index}>
<div className="flex-1">
<InputField
label={index === 0 ? "Name" : undefined}
name={`members[${index}].name`}
placeholder="Ada Lovelace"
/>
</div>
<div className="flex-1">
<EmailField
label={index === 0 ? "Email" : undefined}
name={`members[${index}].email`}
/>
</div>
<Button
onClick={() => arrayField.removeValue(index)}
type="button"
variant="ghost"
>
<TrashIcon />
</Button>
</div>
))}
<Button
onClick={() => arrayField.pushValue({ email: "", name: "" })}
type="button"
variant="outline"
>
Add member
</Button>
</FieldSet>
)}
</ArrayField>
</FieldGroup>
<ResetButton />
<SubmitButton>Send invites</SubmitButton>
</Form>
</FormProvider>
)
}Conditional fields
form.Subscribe reads another field's value and re-renders only its own
children when that value changes, not the whole form. Here, switching the
country swaps the second field between a SelectField and an
InputField. To read state in a hook instead, such as to debounce a value,
use useSelector(form.store, (state) => state.values.country), also
exported by create-form.
"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"
export function Shipping() {
const form = useForm({
defaultValues: { country: "US", region: "", state: "" },
onSubmit: ({ value }) => saveAddress(value),
})
const {
InputField,
ResetButton,
SelectField,
SubmitButton,
Subscribe,
} = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<SelectField label="Country" name="country">
<SelectItem value="US">United States</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectField>
<Subscribe selector={(state) => state.values.country}>
{(country) =>
country === "US" ? (
<SelectField label="State" name="state" placeholder="Pick a state">
<SelectItem value="CA">California</SelectItem>
<SelectItem value="NY">New York</SelectItem>
<SelectItem value="TX">Texas</SelectItem>
</SelectField>
) : (
<InputField label="Region" name="region" placeholder="Region" />
)
}
</Subscribe>
</FieldGroup>
<ResetButton />
<SubmitButton>Save address</SubmitButton>
</Form>
</FormProvider>
)
}Dynamic fields
Pass listeners.onChange to a field to react to its value changes; call
form.setFieldValue(...) inside the listener to update another field.
Here, switching the dietary preference unticks incompatible meals so the
form value stays consistent. The schema stores meals as a nested object
(meals.salad, meals.sausage, meals.eggs), so each <CheckboxField>
binds to its own boolean leaf.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup, FieldLegend, FieldSet } from "@zeno-lib/ui/field"
import { SelectItem } from "@zeno-lib/ui/select"
import { z } from "zod"
type Diet = "none" | "vegetarian" | "vegan"
export function Order() {
const form = useForm({
onSubmit: ({ value }) => saveOrder(value),
schema: z.object({
dietary: z.enum(["none", "vegetarian", "vegan"]).default("none"),
meals: z.object({
salad: z.boolean().default(true),
sausage: z.boolean().default(true),
eggs: z.boolean().default(true),
}),
}),
})
const { CheckboxField, SelectField, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<SelectField
label="Dietary restrictions"
name="dietary"
listeners={{
onChange: ({ value }: { value: Diet }) => {
if (value !== "none") form.setFieldValue("meals.sausage", false)
if (value === "vegan") form.setFieldValue("meals.eggs", false)
},
}}
>
<SelectItem value="none">No restrictions</SelectItem>
<SelectItem value="vegetarian">Vegetarian</SelectItem>
<SelectItem value="vegan">Vegan</SelectItem>
</SelectField>
<FieldSet>
<FieldLegend>Meals</FieldLegend>
<FieldGroup className="gap-3">
<CheckboxField label="Salad" name="meals.salad" />
<CheckboxField label="Sausage" name="meals.sausage" />
<CheckboxField label="Eggs" name="meals.eggs" />
</FieldGroup>
</FieldSet>
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}Numeric IDs
Both SelectField and ComboboxField accept numeric values, which suits
database rows whose primary key is a number. Declare the schema field as
z.number() and submit the form to see categoryId arrive as a number, not a
string.
Select
Pass value={cat.id} to each <SelectItem>. The trigger displays the label
because SelectField derives Base UI's items map from the <SelectItem>
children.
"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 categories = [
{ id: 1, name: "Technology" },
{ id: 2, name: "Design" },
{ id: 3, name: "Marketing" },
]
const form = useForm({
schema: z.object({
categoryId: z.number({ error: "Pick a category" }),
}),
})
const { SelectField, SubmitButton } = form
<FormProvider form={form}>
<Form>
<FieldGroup>
<SelectField label="Category" name="categoryId" placeholder="Pick a category">
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectField>
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>Combobox
Pass items as { value, label } objects when the value isn't a string. Base
UI uses label for both the input display and the built-in filter, and
returns value (the numeric ID) to the form.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const categories = [
{ value: 1, label: "Technology" },
{ value: 2, label: "Design" },
{ value: 3, label: "Marketing" },
]
const form = useForm({
schema: z.object({
categoryId: z.number({ error: "Pick a category" }),
}),
})
const { ComboboxField, SubmitButton } = form
<FormProvider form={form}>
<Form>
<FieldGroup>
<ComboboxField
items={categories}
label="Category"
name="categoryId"
placeholder="Search categories…"
/>
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>Combobox: multi-select, grouped, creatable
For advanced Combobox cases, skip the ComboboxField wrapper and compose
the Base UI parts yourself. This example combines four Base UI features
into one field:
multiple: chips render inside the trigger via<ComboboxValue>.- Input inside popup: the search input sits in the popup instead of the trigger, so the trigger holds only the chips.
- Grouped:
<ComboboxList>iterates groups;<ComboboxGroup items={group.items}>and<ComboboxCollection>iterate items. - Creatable: when the query has no exact match, the field appends an
extra group with a
Create "…"item. Selecting it appends a custom skill to local state and to the form value.
The field connects to the form through <AppField> and
useFieldContext<string[]>(). Items follow Base UI's { value, label }
shape, so the chip text comes from label. Pass isItemEqualToValue as
well, so items compare by value rather than by reference when
groupsForView is rebuilt on each render.
"use client"
import { Form, FormProvider, useFieldContext, useForm } from "@zeno-lib/forms/create-form"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@zeno-lib/ui/combobox"
import { Field, FieldGroup, FieldLabel } from "@zeno-lib/ui/field"
import { useState } from "react"
import { z } from "zod"
type SkillItem = { value: string; label: string; creatable?: boolean }
type SkillGroup = { value: string; items: SkillItem[] }
const baseGroups: SkillGroup[] = [
{ value: "Frontend", items: [/* … */] },
{ value: "Backend", items: [/* … */] },
{ value: "DevOps", items: [/* … */] },
]
function SkillsField() {
const field = useFieldContext<string[]>()
const [query, setQuery] = useState("")
const [customSkills, setCustomSkills] = useState<SkillItem[]>([])
const anchorRef = useComboboxAnchor()
const allGroups = customSkills.length
? [...baseGroups, { value: "Custom", items: customSkills }]
: baseGroups
const allItems = allGroups.flatMap((g) => g.items)
// Append a "Create …" item when the query has no exact match.
const trimmed = query.trim()
const hasMatch = allItems.some(
(s) => s.label.toLowerCase() === trimmed.toLowerCase()
)
const groupsForView = !trimmed || hasMatch
? allGroups
: [
...allGroups,
{
value: "New",
items: [{ value: trimmed, label: `Create "${trimmed}"`, creatable: true }],
},
]
// Field state is string[]; resolve to SkillItem[] so chips can read .label.
const selectedValues = field.state.value ?? []
const selectedItems = selectedValues
.map((v) => allItems.find((s) => s.value === v))
.filter((s): s is SkillItem => s != null)
return (
<Field>
<FieldLabel htmlFor={field.name}>Skills</FieldLabel>
<Combobox
items={groupsForView}
multiple
value={selectedItems}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(a, b) => a.value === b.value}
onValueChange={(next: SkillItem[]) => {
const created = next.find((i) => i.creatable)
if (created) {
const newSkill = { value: created.value.toLowerCase(), label: created.value }
setCustomSkills((c) => [...c, newSkill])
field.handleChange([...selectedValues, newSkill.value])
setQuery("")
} else {
field.handleChange(next.map((i) => i.value))
}
}}
>
<ComboboxChips ref={anchorRef}>
<ComboboxValue>
{(value: SkillItem[]) =>
value.map((item) => (
<ComboboxChip key={item.value}>{item.label}</ComboboxChip>
))
}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchorRef}>
<ComboboxInput placeholder="Search or type to create…" showTrigger={false} />
<ComboboxEmpty>No skill matches.</ComboboxEmpty>
<ComboboxList>
{(group: SkillGroup) => (
<ComboboxGroup items={group.items} key={group.value}>
<ComboboxLabel>{group.value}</ComboboxLabel>
<ComboboxCollection>
{(item: SkillItem) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
)
}
export function SkillsPicker() {
const form = useForm({
schema: z.object({
skills: z.array(z.string()).min(1, "Pick at least one skill"),
}),
})
const { AppField, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<AppField name="skills">{() => <SkillsField />}</AppField>
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}Submit from outside the form
When a submit button can't live inside the <form> element (a card
footer, drawer action bar, sticky page footer), wrap everything in
<FormProvider> and add an id to <Form>. FormProvider extends the
form context beyond the <form> element, and form="…" on
SubmitButton links it for native submission. ResetButton reads
context the same way and needs no extra wiring.
"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({
name: z.string().min(2, "At least 2 characters"),
})
export function Settings() {
const form = useForm({
onSubmit: async ({ value }) => {
await saveSettings(value)
},
schema,
})
const { InputField, ResetButton, SubmitButton } = form
return (
<FormProvider form={form}>
<Form id="settings-form">
<FieldGroup>
<InputField label="Name" name="name" placeholder="Ada Lovelace" />
</FieldGroup>
</Form>
{/* Outside the <form> element, FormProvider keeps context alive */}
<ResetButton />
<SubmitButton form="settings-form">Save</SubmitButton>
</FormProvider>
)
}Async-search combobox
Load the option list from a server. Pass inputValue and
onInputValueChange to take control of the search text, filter={null}
to disable client-side matching (the items you supply are already
filtered), and loading to show a spinner inside the input. Debounce in
your useEffect so each keystroke doesn't fire a request. The full prop
table is on
Combobox Field.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { useEffect, useState } from "react"
async function searchUsers(query: string): Promise<string[]> {
const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`)
return (await response.json()) as string[]
}
export function AssignReviewer() {
const [query, setQuery] = useState("")
const [items, setItems] = useState<string[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const trimmed = query.trim()
if (!trimmed) {
setItems([])
setLoading(false)
return
}
setLoading(true)
let cancelled = false
const handle = window.setTimeout(async () => {
const results = await searchUsers(trimmed)
if (!cancelled) {
setItems(results)
setLoading(false)
}
}, 250)
return () => {
cancelled = true
window.clearTimeout(handle)
}
}, [query])
const form = useForm({ defaultValues: { assignee: "" } })
const { ComboboxField } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<ComboboxField
emptyMessage={
query.trim() && !loading ? "No matches." : "Type to search…"
}
filter={null}
inputValue={query}
items={items}
label="Assignee"
loading={loading}
name="assignee"
onInputValueChange={setQuery}
placeholder="Search teammates…"
/>
</FieldGroup>
</Form>
</FormProvider>
)
}Server-side error handler
When the server rejects a submission with per-field messages, throw a
ValidationError from anywhere inside onSubmit (typically your API
client). useForm catches it and writes each field message onto the
matching field. Each message clears the first time the user edits that
field.
"use client"
import { Form, FormProvider, useForm, ValidationError } 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"),
username: z.string().min(2, "At least 2 characters"),
})
async function createAccount(value: z.infer<typeof schema>) {
const response = await fetch("/api/account", {
body: JSON.stringify(value),
method: "POST",
})
if (response.status === 422) {
const body = (await response.json()) as { fields: Record<string, string> }
throw new ValidationError(body.fields)
}
}
export function CreateAccount() {
const form = useForm({
onSubmit: ({ value }) => createAccount(value),
schema,
})
const { EmailField, InputField, SubmitButton } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField label="Username" name="username" />
<EmailField name="email" />
</FieldGroup>
<SubmitButton>Create</SubmitButton>
</Form>
</FormProvider>
)
}You can throw ValidationError from any code onSubmit calls: the API
client, an async refinement, a custom guard. Pass an array of strings to
show several messages on the same field, and an optional formError for
a top-level message:
throw new ValidationError(
{ email: ["Already taken", "Domain is blocked"] },
{ formError: "Account creation is paused for new orgs." }
)If you need a server message that persists until the next submit instead
of clearing on the next keystroke, call formApi.setFieldMeta directly.
Server actions
A Next.js server action should return its validation failure rather than
throw it, because in production Next.js replaces a thrown error's message
before it reaches the browser. defineFormAction from @zeno-lib/db/next
returns an ActionResult ({ ok: true, data } or { ok: false, error: { fieldErrors, formErrors } }), and submitAction routes it back into the form. Pass
onSubmit's own argument straight through:
"use client"
import { Form, FormProvider, submitAction, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
import { updateTeamAction } from "./actions" // a defineFormAction export
const schema = z.object({
members: z.array(z.object({ email: z.email(), share: z.string() })),
name: z.string().min(2),
})
export function EditTeam({ team }: { team: z.input<typeof schema> }) {
const form = useForm({
defaultValues: team,
onSubmit: (submit) => submitAction(submit, updateTeamAction, { reset: true }),
schema,
})
// …fields named "name", "members[0].email", "members[0].share", …
}What submitAction(submit, action, options?) does:
- Field errors land on their fields, array paths included: an error keyed
members[1].emailshows under the field registered with that name. Each message clears the first time the user edits that field; the others stay.submitActionfolds a key that no mounted field registered into the form-level message instead, so it can't block the submit button without showing anywhere. formErrorsbecome the form-level error, atform.state.errorMap.onSubmit(one message per line).schema(optional):onSubmitreceives the form's input values. For a schema that transforms (z.string().transform(Number)), pass it here:submitActionvalidates the values on the client and hands the schema's output to the action. On a failure, it applies the errors to the form and never calls the action.reset(optional): after a success,truecallsformApi.reset(data), so the returned record becomes the pristine baseline. The types only offertruewhen the data has the form's shape."values"rebases on the submitted values, and a function maps the data to the next values.- It returns the result, typed from the action, so you can redirect or
toast on
result.ok. A thrown error (network, auth, a bug) propagates as is.
"use client"
import { type ActionResult, Form, FormProvider, submitAction, useForm } from "@zeno-lib/forms/create-form"
import { FieldGroup } from "@zeno-lib/ui/field"
import { z } from "zod"
const schema = z.object({
members: z.array(z.object({ email: z.email("Enter a valid email") })),
team: z.string().min(2, "At least 2 characters"),
})
type Team = z.infer<typeof schema>
// Stand-in for a defineFormAction server action.
async function inviteTeam(input: Team): Promise<ActionResult<Team>> {
const fieldErrors: Record<string, string[]> = {}
for (const [index, member] of input.members.entries()) {
if (member.email === "taken@example.com") {
fieldErrors[`members[${index}].email`] = ["Already on another team."]
}
}
const formErrors = input.team === "admin" ? ["That team name is reserved."] : []
if (Object.keys(fieldErrors).length > 0 || formErrors.length > 0) {
return { error: { fieldErrors, formErrors }, ok: false }
}
return { data: input, ok: true }
}
export function InviteTeam() {
const form = useForm({
defaultValues: {
members: [{ email: "ada@example.com" }, { email: "taken@example.com" }],
team: "Analytical engines",
},
onSubmit: (submit) => submitAction(submit, inviteTeam),
schema,
})
const { EmailField, InputField, SubmitButton, Subscribe } = form
return (
<FormProvider form={form}>
<Form>
<FieldGroup>
<InputField label="Team" name="team" />
<EmailField label="Member 1" name="members[0].email" />
<EmailField label="Member 2" name="members[1].email" />
<Subscribe selector={(state) => state.errorMap.onSubmit}>
{(formError) =>
typeof formError === "string" ? <p role="alert">{formError}</p> : null
}
</Subscribe>
</FieldGroup>
<SubmitButton>Invite</SubmitButton>
</Form>
</FormProvider>
)
}The result type is structural. @zeno-lib/forms declares ActionResult itself
and does not depend on @zeno-lib/db, so any action resolving to that shape
works, hand-written ones included. applyActionError(formApi, error) writes the
errors the same way without calling an action, for a result you already have.
reset rebases the form, but TanStack Form re-applies defaultValues on the
next render while the form is untouched. It sticks when defaultValues
follows the saved record, which is what happens when the action calls
revalidatePath and the page passes the fresh record down. With
defaultValues fixed in the component, the next render restores them.
Unsaved-changes warning
Pass unsavedChangesWarning: "if-changed" (or the shorthand true) to
useForm and the browser prompts before navigating away while the
form's current values differ from its defaults. After a successful save,
call formApi.reset(value) to rebase the defaults on the saved values.
The warning clears, and the form compares further edits against the new
baseline. The rebase sticks across re-renders even though the component
keeps passing the same defaultValues. useForm re-applies
defaultValues only when they deeply change (a refetched record, for
example), and, as in TanStack Form, only while the form is untouched.
There are two trigger modes:
"if-changed"(recommended): fires while the values differ from the defaults, and clears when the user restores the originals or after aformApi.reset(value)that follows a save."if-touched": fires once the user has edited any field, even if they reverted the edit, and stays on untilformApi.reset().
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { Badge } from "@zeno-lib/ui/badge"
import { FieldGroup } from "@zeno-lib/ui/field"
export function EditPost() {
const form = useForm({
defaultValues: { body: "", title: "" },
onSubmit: ({ formApi, value }) => {
savePost(value)
formApi.reset(value) // rebase defaults so the badge clears
},
unsavedChangesWarning: "if-changed",
})
const { InputField, Subscribe, SubmitButton, TextAreaField } = form
return (
<FormProvider form={form}>
<Subscribe selector={(state) => !state.isDefaultValue}>
{(changed) =>
changed ? <Badge variant="destructive">Unsaved changes</Badge> : null
}
</Subscribe>
<Form>
<FieldGroup>
<InputField label="Title" name="title" />
<TextAreaField label="Body" name="body" rows={4} />
</FieldGroup>
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}The Subscribe block reads the same state as the warning: select
!state.isDefaultValue for "if-changed", or state.isDirty for the
sticky "if-touched" behaviour.
unsavedChangesWarning only covers full-page navigation (closing the
tab, typing a new URL, hitting the back button). For client-side route
changes in a single-page app, read the matching state flag from the form
and prompt before pushing the next route. Most routers expose a
useBeforeUnload-style hook for this.