Skip to main content

Form

The field primitives (Field, Label, Input, FieldError) do not care where a value comes from. Form and FormField connect them to react-hook-form so a validated field is a few lines and the error wiring is automatic.

import Form from '@zuilib/primitives/form'
import FormField from '@zuilib/primitives/form-field'
import { useAutosave } from '@zuilib/primitives/lib/use-autosave'

react-hook-form is an optional peer of @zuilib/primitives. Install it when you import these subpaths; nothing else in the package needs it.

pnpm add react-hook-form

A validated field

Form wraps FormProvider and a <form> whose submit runs form.handleSubmit(onSubmit). FormField is a Controller that renders its function child inside a Field: the label, the control and the error are associated automatically, and the field turns invalid whenever it has an error.

Loading example

Submit the empty form to see the error appear and the input turn invalid.

Controlled components

Checkbox, Switch, Select, Combobox and RadioGroup are not native inputs, so spread nothing. Wire checked or value and the change handler from field by hand:

<FormField control={form.control} name="terms">
{({ field }) => (
<Checkbox checked={field.value} onCheckedChange={field.onChange} label="I agree" />
)}
</FormField>

Autosave

onAutosave on a FormField debounces a save of that field's value and exposes the state as the autosave render prop. The same behaviour is available standalone as useAutosave.

<FormField control={form.control} name="bio" onAutosave={(value) => api.patchBio(value)} autosaveDebounceMs={800}>
{({ field, autosave, error }) => (
<>
<Label unsaved={autosave?.status === 'unsaved'}>Bio</Label>
<Textarea {...field} />
<FieldError error={error} />
</>
)}
</FormField>
const { status, lastSaved, error, retry } = useAutosave({
value: watchedValue,
onAutosave: async (value) => api.save(value),
debounceMs: 1000,
})
// status: 'idle' | 'unsaved' | 'saving' | 'saved' | 'error'

Props

FormProps extends form props (minus onSubmit).

PropTypeDescription
formUseFormReturn<TFieldValues>The useForm() result; rendered as a FormProvider
onSubmit(data: TFieldValues) => void | Promise<void>Wired through form.handleSubmit
childrenReactNode

FormFieldProps extends react-hook-form's ControllerProps (minus render).

PropTypeDefaultDescription
controlControl<TFieldValues>From useForm(); falls back to the enclosing Form
nameFieldPath<TFieldValues>
children(props: FormFieldRenderProps) => ReactNodeA function child, not render
onAutosave(value) => Promise<void>Enables a debounced save of the field value
autosaveDebounceMsnumber1000

FormFieldRenderProps:

PropertyTypeUse
fieldControllerRenderPropsSpread on a native control, or read value / onChange for a controlled one
errorFieldError | undefinedPass to FieldError
ids{ control, description, message }Optional: wire htmlFor / id by hand. An explicit id wins over the generated one
autosaveAutosaveState | undefinedPresent when onAutosave is set

AutosaveState is { status, lastSaved, error, retry }.

Accessibility

  • FormField renders a Field, so for, aria-labelledby and aria-describedby are generated; no ids needed.
  • The field is invalid while it has an error: every control inside gets aria-invalid and the error styling.
  • FieldError is a live region, so a validation message is announced when it appears.
  • Field: what FormField renders around your control.
  • FieldError: takes the error render prop directly.
  • Toast: toast.error on a failed submit.