Examples
Composed @zeno-lib/forms examples, from a simple login to dynamic and array fields
A walk-through from layout patterns to per-feature recipes. Each example below is fully working: 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
Drop the form into a <Card> with header on top, fields in
<CardContent>, actions in <CardFooter>. <FormProvider> wraps the
whole <Card> so the buttons in <CardFooter> (which sit outside the
<form> element) still resolve ResetButton and SubmitButton. The
id on <Form> plus form="…" on <SubmitButton> keeps native
submission working (Enter-to-submit, accessibility).
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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"
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="Resolve admin" />
<InputField
description="Used in URLs."
label="Slug"
name="slug"
placeholder="resolve-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>
)
}Simple login
A two-field form with a Zod schema. name autocompletes against
defaultValues.
"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 Login() {
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>
)
}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 throttle). Drop 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"
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"
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 placeholder="you@zeno.dev" />
<PasswordField autoComplete="new-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"
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 other field values reactively without re-rendering
the entire form. Here, switching the country swaps the second field
between a SelectField and an InputField.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 cascade an update onto
another field. Here, switching dietary preference auto-unticks incompatible
meals so the form value stays consistent. Meals are stored 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"
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 directly — useful
for 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 automatically.
"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 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"
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
The wrapper-free pattern for advanced Combobox use cases. This example combines four Base UI features into one field:
multiple— chips render inside the trigger via<ComboboxValue>.- Input inside popup — search lives in the popup, not in the trigger, so the chips have room to breathe.
- Grouped —
<ComboboxList>iterates groups;<ComboboxGroup items={group.items}>+<ComboboxCollection>iterate items. - Creatable — when the query has no exact match, an extra group is
appended with a
Create "…"item. Selecting it appends a custom skill to local state and to the form value.
Wired to the form with <AppField> + useFieldContext<string[]>(). Items
follow Base UI's { value, label } shape so the chip text comes from
label automatically — pair with isItemEqualToValue so referential
equality doesn't bite when groupsForView is rebuilt each render.
"use client"
import { Form, FormProvider, useFieldContext, useForm } from "@zeno-lib/forms"
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"
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
Drive the option list from a server. Pass inputValue /
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 one keystroke doesn't fire one request. Full prop
table on
Combobox Field.
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 automatically. The error clears on the next keystroke, so
the user sees feedback once and it goes away as they fix the value.
"use client"
import { Form, FormProvider, useForm, ValidationError } 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"),
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 />
</FieldGroup>
<SubmitButton>Create</SubmitButton>
</Form>
</FormProvider>
)
}ValidationError is throwable from anywhere reachable during submit:
the API client, an async refinement, a custom guard. Pass an array of
strings to surface multiple 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.
Unsaved-changes warning
Pass unsavedChangesWarning: "if-changed" (or 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 defaults to the saved values: the
warning clears, and any further edits are diffed against the new
baseline.
Two trigger modes:
"if-changed"(recommended): fires while values differ from defaults. Clears if the user restores the originals, or afterformApi.reset(value)post-save."if-touched": fires after the user has edited any field, even if they reverted (sticky once edited untilformApi.reset()).
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms"
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 follows the same pattern as the warning: select
!state.isDefaultValue for "if-changed", or state.isDirty for the
sticky "if-touched" feel.
unsavedChangesWarning only covers full-page navigation (closing the
tab, typing a new URL, hitting the back button). For client-side route
changes inside a SPA, read the matching state flag from the form and
prompt before pushing the next route. Most routers expose a
useBeforeUnload-style hook for this.