ZenoZENO

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).

Display name
How we'll address you in the app.
"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.

Project
Open the dialog to create a new project.
"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 in dialog.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 with form="id"), shows a spinner while submitting, and the dialog closes when the submit succeeds (closeOnSubmit, default true). A failed validation, a thrown error, or a ValidationError keeps it open.
pnpm dlx shadcn@latest add zeno-lib/zeno/form-dialog
Members
Edit a row, change something, then press Escape.
Ada Lovelace
Grace Hopper
"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 propTypeWhat it does
dialogFormDialogControllerThe controller from useFormDialog().
formthe useForm() resultIts onSubmit runs on Save.
title / descriptionReactNodeDialog header.
triggerReactElementOpens a session with the hook's defaults.
submitLabel / cancelLabelReactNodeDefault to "Save" / "Cancel".
footerReactNodeExtra footer content, before the buttons.
closeOnSubmitbooleanClose after a successful submit. Defaults to true.
guardbooleanThe unsaved-changes prompt and unload warning. Defaults to true.
discardPrompt{ title?, description?, confirmLabel?, cancelLabel? }Copy for the discard prompt.
className / formClassNamestringClasses 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 new defaultValues once a form is touched, and a form.reset(values) is undone on the next render if useForm still receives the old defaults (the reset leaves the form untouched, so TanStack re-applies its options). Passing dialog.defaultValues to useForm keeps both in step, and FormDialog calls form.reset(defaults) at the start of each session. For the same reason, don't reset inside onSubmit; the dialog resets itself after closing.

Simple login

A two-field form with a Zod schema. name autocompletes against defaultValues.

Login to your account
Enter your email and password to sign in.
"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.

Account settings
Submit is blocked while the email check runs. Try "taken@example.com".
"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.

Create your account
Pick an email and password — confirmation must match.
"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.

Invite team members
Add as many teammates as you need — each row is its own array entry.
Members
"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.

Shipping address
Switch country to see the second field swap reactively.
"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.

Order
Switching dietary preference unticks incompatible meals automatically.
Meals
"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.

Assign category
Submit to see the ID arrive as a number, not a string.
"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.

Search category
Submit to see the ID arrive as a number, not a string.
"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.

Profile skills
Multi-select, grouped, with search inside the popup and on-the-fly create.
"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.

Settings
"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.

Assign reviewer
Type to search the team. Results load from a (simulated) API with a 350 ms delay.
"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.

Create account
Try taken@example.com or admin to see server-side errors land on the right 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].email shows under the field registered with that name. Each message clears the first time the user edits that field; the others stay. submitAction folds a key that no mounted field registered into the form-level message instead, so it can't block the submit button without showing anywhere.
  • formErrors become the form-level error, at form.state.errorMap.onSubmit (one message per line).
  • schema (optional): onSubmit receives the form's input values. For a schema that transforms (z.string().transform(Number)), pass it here: submitAction validates 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, true calls formApi.reset(data), so the returned record becomes the pristine baseline. The types only offer true when 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.
Invite a team
Submit as is: the second row comes back with a server error. Edit it to clear it. Name the team admin for a form-level error.
"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 a formApi.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 until formApi.reset().
Edit post
Type something, then close the tab — the browser prompts before navigating away. Submit or reset to clear the dirty state.
"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.