Zeno

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

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

Project
Open the dialog to create a new project.
"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.

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

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

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

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

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

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

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

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

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

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"
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 after formApi.reset(value) post-save.
  • "if-touched": fires after the user has edited any field, even if they reverted (sticky once edited 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"
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.