Inputs
FormPro
Forms on React Hook Form and Zod: fields wired to labels and errors, conditional fields, an error summary, server errors per field, and autosave with a status line.
npx prodular-ui add formPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Billing details
Submit it empty to see the error summary. VAT shows for EU countries, PO and terms for invoices. DE000000000 comes back as a server error on its field.
"use client"
import * as React from "react"import { z } from "zod"
import { Form, FormErrorSummary, FormField, FormSubmit, FormWhen, useAppForm } from "@/components/ui/form"import { Input } from "@/components/ui/input"import { NumberInput } from "@/components/ui/number-input"import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const countries = [ { value: "US", label: "United States" }, { value: "DE", label: "Germany" }, { value: "PL", label: "Poland" }, { value: "GB", label: "United Kingdom" },]const eu = ["DE", "PL"]const terms = [ { value: "net15", label: "Net 15" }, { value: "net30", label: "Net 30" }, { value: "net45", label: "Net 45" },]
// Fields that only exist for one payment method live in a discriminated union,// so hidden fields are never validated.const schema = z.intersection( z.object({ company: z.string().trim().min(1, "Enter the legal name of the company."), email: z.email("Enter an email address, like billing@company.com."), country: z.string().min(1, "Pick a country."), vatId: z.string().optional(), seats: z.number({ error: "Enter how many seats you need." }).int().min(1, "At least one seat.").max(500, "Over 500 seats, talk to sales."), }), z.discriminatedUnion("payment", [ z.object({ payment: z.literal("card") }), z.object({ payment: z.literal("invoice"), po: z.string().trim().min(1, "Invoices need a purchase order number."), terms: z.string(), }), ]))type Billing = z.infer<typeof schema>
export default function FormBilling() { const [saved, setSaved] = React.useState<string | null>(null) const form = useAppForm(schema, { defaultValues: { company: "Halcyon Labs", email: "", country: "DE", vatId: "", seats: 42, payment: "invoice", po: "", terms: "net30" } as Billing, })
return ( <Form form={form} className="w-full max-w-md" onSubmit={async (values) => { await new Promise((r) => setTimeout(r, 700)) // The server rejects one VAT number, to show a field error from the backend. if (values.vatId === "DE000000000") return { errors: { vatId: "The EU VIES service does not recognize this VAT number." } } setSaved(`Saved billing for ${values.company}, ${values.seats} seats.`) }} > <FormErrorSummary /> <FormField<Billing, "company"> name="company" label="Company"> {(field) => <Input {...field} autoComplete="organization" />} </FormField> <FormField<Billing, "email"> name="email" label="Billing email" description="Invoices and receipts go here."> {(field) => <Input {...field} type="email" autoComplete="email" placeholder="billing@halcyonlabs.com" />} </FormField> <div className="grid gap-6 sm:grid-cols-2"> <FormField<Billing, "country"> name="country" label="Country"> {(field) => ( <Select items={countries} value={field.value} onValueChange={(v) => field.onChange(v)}> <SelectTrigger className="w-full" onBlur={field.onBlur} ref={field.ref} aria-invalid={field["aria-invalid"]}> <SelectValue /> </SelectTrigger> <SelectContent> {countries.map((c) => ( <SelectItem key={c.value} value={c.value}> {c.label} </SelectItem> ))} </SelectContent> </Select> )} </FormField> <FormField<Billing, "seats"> name="seats" label="Seats"> {(field) => <NumberInput value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} inputRef={field.ref} min={1} />} </FormField> </div> <FormWhen name="country" is={eu}> <FormField<Billing, "vatId"> name="vatId" label="VAT number" description="Shown for EU countries. Try DE000000000 to see a server error."> {(field) => <Input {...field} value={field.value ?? ""} placeholder="DE123456789" />} </FormField> </FormWhen> <FormField<Billing, "payment"> name="payment" label="Payment method"> {(field) => ( <RadioGroup value={field.value} onValueChange={(v) => field.onChange(v)} className="flex gap-6"> <label className="flex items-center gap-2 text-sm"> <RadioGroupItem value="card" /> Card </label> <label className="flex items-center gap-2 text-sm"> <RadioGroupItem value="invoice" /> Invoice </label> </RadioGroup> )} </FormField> <FormWhen name="payment" is="invoice" className="sm:grid sm:grid-cols-2 sm:gap-6"> <FormField<Billing, "po"> name="po" label="PO number"> {(field) => <Input {...field} value={field.value ?? ""} placeholder="PO-2026-118" />} </FormField> <FormField<Billing, "terms"> name="terms" label="Payment terms"> {(field) => ( <Select items={terms} value={field.value} onValueChange={(v) => field.onChange(v)}> <SelectTrigger className="w-full" ref={field.ref}> <SelectValue /> </SelectTrigger> <SelectContent> {terms.map((t) => ( <SelectItem key={t.value} value={t.value}> {t.label} </SelectItem> ))} </SelectContent> </Select> )} </FormField> </FormWhen> <div className="flex items-center gap-3"> <FormSubmit>Save billing details</FormSubmit> {saved && <p className="text-[13px] text-success-subtle-foreground">{saved}</p>} </div> </Form> )}Autosave
Valid changes save after a pause in typing, with no button. Invalid ones wait. Turn on the network failure to see the retry.
"use client"
import * as React from "react"import { z } from "zod"
import { Checkbox } from "@/components/ui/checkbox"import { Form, FormField, FormStatus, useAppForm } from "@/components/ui/form"import { Input } from "@/components/ui/input"import { Switch } from "@/components/ui/switch"import { Textarea } from "@/components/ui/textarea"
const schema = z.object({ name: z.string().trim().min(2, "Use at least two characters."), title: z.string().max(60, "Keep it under 60 characters."), bio: z.string().max(240, "Keep it under 240 characters."), digest: z.boolean(),})type Profile = z.infer<typeof schema>
export default function FormAutosave() { const [offline, setOffline] = React.useState(false) const offlineRef = React.useRef(offline) React.useEffect(() => { offlineRef.current = offline }, [offline])
const form = useAppForm(schema, { defaultValues: { name: "Maya Okafor", title: "Head of finance", bio: "Runs month end close for Halcyon Labs.", digest: true }, })
return ( <div className="flex w-full max-w-md flex-col gap-4"> <Form form={form} autosave={{ delay: 700, onSave: async () => { await new Promise((r) => setTimeout(r, 600)) if (offlineRef.current) throw new Error("You are offline. Changes are kept here.") }, }} className="gap-5" > <div className="flex items-center justify-between border-b pb-3"> <p className="text-sm font-medium">Profile</p> <FormStatus /> </div> <FormField<Profile, "name"> name="name" label="Display name"> {(field) => <Input {...field} autoComplete="name" />} </FormField> <FormField<Profile, "title"> name="title" label="Title"> {(field) => <Input {...field} />} </FormField> <FormField<Profile, "bio"> name="bio" label="About" description="Shown on your approvals and comments."> {(field) => <Textarea {...field} rows={3} />} </FormField> <FormField<Profile, "digest"> name="digest" label="Weekly digest email" orientation="horizontal" className="justify-between"> {(field) => <Switch checked={field.value} onCheckedChange={field.onChange} />} </FormField> </Form> <label className="flex items-center gap-2 text-[13px] text-muted-foreground"> <Checkbox checked={offline} onCheckedChange={(v) => setOffline(Boolean(v))} /> Simulate a network failure </label> </div> )}Anatomy
const form = useAppForm(schema, { defaultValues })
<Form form={form} onSubmit={save}> <FormErrorSummary /> <FormField name="email" label="Billing email"> {(field) => <Input {...field} type="email" />} </FormField> <FormWhen name="payment" is="invoice"> <FormField name="po" label="PO number">{(field) => <Input {...field} />}</FormField> </FormWhen> <FormSubmit /></Form>Guidance
Use it when
- Any form with more than two fields, validation or server errors: settings, billing, onboarding, create and edit dialogs.
- autosave for settings pages where every field stands on its own; a submit button where changes only make sense together.
Avoid
- One field edited in place. Use Inline edit.
- Autosave on forms that start something irreversible, like payments or invites. Submit those.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Define the schema with Zod and create the form with useAppForm(schema, { defaultValues }). Give every field a default value.
- Put fields that depend on another field in a z.discriminatedUnion and show them with FormWhen, so hidden fields are never validated.
- Render each control through FormField: spread field onto Input and Textarea; for Select, Switch, Number input and other value APIs map value and onChange by hand, and pass field.ref so the error summary can focus it.
- Return { errors: { field: message } } from onSubmit for server validation and { formError } for anything else. Do not show toasts for field errors.
- Put FormErrorSummary at the top of long forms and FormSubmit at the end. Write error messages that say how to fix the value.
API
useAppForm
| Prop | Type | Default | Description |
|---|---|---|---|
| (schema, { defaultValues, mode }) | UseFormReturn<T> | React Hook Form with a Zod resolver. mode defaults to onTouched. |
Form
| Prop | Type | Default | Description |
|---|---|---|---|
| form | UseFormReturn<T> | The form from useAppForm. | |
| onSubmit | (values) => void | { errors?, formError? } | Runs with valid values. Return errors to show them on fields or for the whole form. | |
| autosave | { onSave(values), delay? } | Saves valid changes after delay ms (800). Throw to show a retry. |
FormField
| Prop | Type | Default | Description |
|---|---|---|---|
| name / label / description | path / ReactNode / ReactNode | The field's path in the values and its copy. The error replaces the description. | |
| children | (field, state) => ReactNode | Renders the control. field has value, onChange, onBlur, ref, name and aria-invalid. |
FormWhen
| Prop | Type | Default | Description |
|---|---|---|---|
| name / is / when | path / value or values / (value) => boolean | Shows its children while the watched field matches. |
FormErrorSummary
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Fix these to continue" | Listed after a failed submit. Each item focuses its field. |
FormStatus
| Prop | Type | Default | Description |
|---|---|---|---|
| - | - | Autosave state: saving, saved at a time, what to fix, or retry. |
FormSubmit
| Prop | Type | Default | Description |
|---|---|---|---|
| ...props | Button props | Submit button with loading while onSubmit runs. |