Forms
Binding Field, Input, Select, DatePicker and the rest to form state and validation.
There is no Form component in this library, and that is a decision rather than an omission.
shadcn ships one, and it is a wrapper around react-hook-form — installing a button should not
decide which form library a product uses. Base UI already gives us
Field, which owns the label, the description, the error and the
aria-* that ties the three to a control. What was missing was not a component but an account of
how to wire it up, which is what this page is.
Read it in order. Most forms in a clinical app are ten fields and a submit button, and the first section is enough for those.
The shortest thing that works
No state, no library, no onChange. The DOM holds the values; Field runs the validation and
renders the error; Base UI's Form collects the values on submit, keyed by each Field's
name.
import { Form } from '@base-ui/react/form'
<Form onFormSubmit={(values) => book(values)}>
<Field
name="accession"
validate={(value) =>
/^ACC-\d{6}$/.test(String(value ?? '')) ? null : 'Six digits, prefixed ACC-.'
}
>
<FieldLabel>Accession number</FieldLabel>
<Input placeholder="ACC-000000" />
<FieldError />
</Field>
<Button type="submit">Assign study</Button>
</Form>Form comes from Base UI directly — it is a <form> with three additions worth having: it sets
noValidate so you get your own error text instead of the browser's yellow bubble, it moves
focus to the first invalid field on a failed submit, and it takes an errors object keyed by
field name so a server's rejection lands on the right control.
validate returns a string to fail and null to pass, and it may be async — an MRN lookup
against the RIS is a legitimate validator. validationMode decides when it runs: onSubmit by
default, then re-validating on change once a field has failed once, which is the behaviour you
want. Telling somebody their MRN is too short while they are typing the first digit is not help.
Reach past this section when the value has to be read while it is being typed — a live filter, a
field that enables another — or when the form is long enough that a schema is cheaper than a pile
of validate props.
When the value lives in React
Two things change once you hold the values yourself.
The first is that Field no longer knows whether the field is valid, so you tell it:
invalid={!!errors.date} on the Field, and match={!!errors.date} on the FieldError to hand
its visibility over to you. Without match, FieldError waits for a validity event that a
controlled component never produces.
The second is the prop names. Controls in this library emit onValueChange, not onChange.
| Component | Read | Write | Value |
|---|---|---|---|
Input | value | onValueChange(value, details) | string |
Select | value | onValueChange(value) | string | string[] | null |
DatePicker | value | onValueChange(date) | Date | DateRange | null |
TimeField | value | onValueChange(time) | string | null — HH:mm |
Checkbox, Switch | checked | onCheckedChange(checked) | boolean |
RadioGroup | value | onValueChange(value) | string |
Textarea | value | onChange(event) | string — a plain <textarea> |
FileTrigger, FileDropzone | — | onSelect(files) | File[] |
Input accepts onChange too, because it renders a real <input>. Select, DatePicker and
TimeField do not — an onChange on any of those is a prop nobody reads, and the field
silently never updates. This is the single most common way a form here is wired wrong, and it
fails without a type error because onChange is a valid DOM prop on the element underneath.
React Hook Form
The common case, and the one qtrack solved by hand-writing eleven form-* wrappers around
@mantine/form. You do not need wrappers. You need register for the two components that are
real inputs, and Controller for everything else.
react-hook-form is not a dependency of this repository, so the two blocks below are static code rather than live previews. Everything above and below them runs.
Input and Textarea render native elements, so register works on them unchanged — it returns
name, onChange, onBlur and a ref, and all four are forwarded:
const { register, control, handleSubmit, formState: { errors } } = useForm<Booking>()
<Field name="mrn" invalid={!!errors.mrn}>
<FieldLabel>MRN</FieldLabel>
<Input placeholder="0000000" {...register('mrn', { pattern: /^\d{7}$/ })} />
<FieldError match={!!errors.mrn}>{errors.mrn?.message}</FieldError>
</Field>Everything else is controlled, and controlled means Controller. The one line that matters is
the rename — field.onChange goes to onValueChange, not to onChange:
<Controller
control={control}
name="date"
rules={{ required: 'Choose a date.' }}
render={({ field, fieldState }) => (
<Field name="date" invalid={fieldState.invalid}>
<FieldLabel htmlFor="appt-date">Date</FieldLabel>
<DatePicker
id="appt-date"
ref={field.ref}
value={field.value ?? null}
onValueChange={field.onChange} {/* not onChange */}
invalid={fieldState.invalid}
aria-invalid={fieldState.invalid || undefined}
aria-describedby={fieldState.error ? 'appt-date-error' : undefined}
/>
<FieldError id="appt-date-error" match={fieldState.invalid}>
{fieldState.error?.message}
</FieldError>
</Field>
)}
/>Three details that bite:
field.onBlurhas to be passed on formode: 'onBlur'andonTouchedto do anything.DatePicker,SelectandTimeFieldall forwardonBlurto the element that receives focus.field.valueisundefinedbefore the first change.DatePickerandTimeFieldtreatundefinedas "uncontrolled" and start managing their own state, so the field stops responding toreset(). Give the form adefaultValueswith an explicitnull, or coalesce as above.- Do not put a
ControlleraroundInput. It works, butregisteris a third of the code and one fewer re-render per keystroke.
If your form library is @mantine/form or formik instead, the shape is the same: whatever it
calls its controlled-field helper, the value goes to value and its change handler goes to
onValueChange.
Where the error goes
Red text near a field is not an error message. An error message is text that a screen reader
reads out when focus reaches the control it belongs to, and that requires two attributes on the
control: aria-invalid, and an aria-describedby naming the element that holds the text.
What Field does for you. Any Base UI control inside a Field registers itself with it, and
Field then owns the wiring. FieldError and FieldDescription each generate an id and push it
into the field's list of message ids; the control gets all of them in its aria-describedby, in
order, and aria-invalid="true" when the field is invalid. That covers Input, Textarea
through FieldControl, Checkbox, Switch, RadioGroup, Select, Autocomplete and
TimeField — which is built on Autocomplete, so it inherits it.
Read off the booking form below with the MRN empty and the form submitted — the ids are Base
UI's, generated by useId:
<input id="base-ui-_R_b4…"
aria-labelledby="base-ui-_R_6b4…" the FieldLabel
aria-invalid="true"
aria-describedby="base-ui-_R_eb4… base-ui-_R_ib4…">
↑ the description ↑ the errorThe same submit puts aria-invalid="true" and the error's id on the Select trigger, the
TimeField input and the consent Checkbox, none of which were told anything beyond
invalid on their surrounding Field.
What it does not do. DatePicker is a Popover trigger — a button we assemble here, not a
Base UI form control — so it registers with nothing, and a Field around it will label it and
style it and tell it nothing. Its ARIA is yours:
<FieldLabel htmlFor="appt-date">Date</FieldLabel>
<DatePicker
id="appt-date"
invalid={!!error} {/* border and announcement */}
aria-describedby={error ? 'appt-date-error' : undefined}
/>
<FieldError id="appt-date-error" match={!!error}>{error}</FieldError>invalid covers both halves — it sets data-invalid for the stylesheet and aria-invalid for
the announcement, so there is no way to ship an error that is visible but silent. htmlFor plus
id is a real label association, because a button is a labelable element. Only
aria-describedby is left to you, since the component cannot know the id of a message it does
not render.
Point aria-describedby at an id only while that element exists. An aria-describedby naming a
missing node is not ignored consistently — some screen readers skip the whole attribute, taking
the description with it.
A booking form
Seven fields, one validator, no library. Worth noticing:
- The validator is one function over the whole value object, not a
validateper field, so a cross-field rule has somewhere to live. Here, MR requires a safety note and the other modalities do not — a rule that cannot be expressed on either field alone. - Errors appear on submit, then track every keystroke — but only after that first failed submit. Before it, the form says nothing.
- The date and the time are separate controls because they fail separately. A radiographer who has picked the date and not the time should be told about the time, and a single combined control cannot say that.
TimeFieldkeeps unreadable input on screen rather than reverting it, and marks itself invalid. Typehhinto it and tab away.
Choosing an approach
| Situation | Reach for |
|---|---|
| A handful of fields, values only needed at submit | Base UI Form + Field validate |
| One field validating as you leave it | Field validationMode="onBlur" |
| A server rejected the submit | <Form errors={{ mrn: 'No such patient' }}> |
| Values needed while typing — live filters, dependent fields | useState + a validator function |
| Cross-field rules, or more than about ten fields | react-hook-form, register + Controller |
| A schema you already own (zod, valibot) | react-hook-form with its resolver |
| A wizard, or a draft that survives a reload | A form library. Do not hand-roll the state |
Reach for a form library later than instinct suggests. A Field with a validate prop is four
lines and no dependency, and the point at which that stops scaling is further away than it looks.
What we deliberately do not ship
- A
Formcomponent. Base UI's is thirty lines of behaviour we have no reason to restyle, and importing it from@base-ui/react/formis one line. A wrapper here would only add a name. FormField/FormItem/FormMessagewrappers. They exist in shadcn to bridge react-hook-form's context to a label and a message.Fieldalready is that bridge, minus the library.- A schema layer. zod belongs to the application, not to a component registry.