Qure UI

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

Printed at the top right of the requisition.

'use client'import * as React from 'react'import { Form } from '@base-ui/react/form'import { Button } from '@/registry/qure/ui/button'import { Checkbox } from '@/registry/qure/ui/checkbox'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import { Input } from '@/registry/qure/ui/input'/** * No form library and no component state for the values — the DOM holds them, * Base UI's Field reports validity, and `onFormSubmit` hands over the values * keyed by each Field's `name`. */export default function FormNative() {  const [accepted, setAccepted] = React.useState<Record<string, unknown> | null>(null)  return (    <Form      className="flex w-full max-w-sm flex-col gap-5"      onFormSubmit={values => setAccepted(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" />        <FieldDescription>Printed at the top right of the requisition.</FieldDescription>        {/* No `match`: shows whatever the last validation produced. */}        <FieldError />      </Field>      <Field name="reader">        <FieldLabel>Reading radiologist</FieldLabel>        <Input placeholder="Dr A. Rao" required />        <FieldError match="valueMissing">Name the radiologist taking the study.</FieldError>      </Field>      <Field name="urgent">        <FieldLabel className="gap-2">          <Checkbox name="urgent" /> Flag as urgent        </FieldLabel>      </Field>      <div className="flex items-center gap-3">        <Button type="submit" size="sm">          Assign study        </Button>        {accepted ? (          <span className="text-label-base text-muted-foreground">            Submitted {String(accepted.accession)}          </span>        ) : null}      </div>    </Form>  )}

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

Today or later.

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { DatePicker } from '@/registry/qure/ui/date-picker'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import {  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/registry/qure/ui/select'const modalities = { ct: 'CT', mr: 'MR', xr: 'X-ray' }/** * Controlled values held in React state, validated on submit. This is the * shape react-hook-form's `Controller` produces — `value` in, `onValueChange` * out — written by hand so the example runs without the dependency. */export default function FormControlled() {  const [modality, setModality] = React.useState<string | null>(null)  const [date, setDate] = React.useState<Date | null>(null)  const [errors, setErrors] = React.useState<{ modality?: string; date?: string }>({})  function submit(event: React.FormEvent) {    event.preventDefault()    const next: typeof errors = {}    if (!modality) next.modality = 'Choose a modality.'    if (!date) next.date = 'Choose a date for the scan.'    else if (date < new Date(new Date().toDateString())) next.date = 'The date is in the past.'    setErrors(next)  }  return (    <form noValidate onSubmit={submit} className="flex w-full max-w-sm flex-col gap-5">      {/* Select registers itself with the Field, so the trigger picks up the          label, the description and the error's id without being told. */}      <Field name="modality" invalid={!!errors.modality}>        <FieldLabel>Modality</FieldLabel>        <Select          items={modalities}          value={modality}          onValueChange={value => setModality(value as string)}        >          <SelectTrigger className="w-full">            <SelectValue placeholder="Not set" />          </SelectTrigger>          <SelectContent>            {Object.entries(modalities).map(([value, label]) => (              <SelectItem key={value} value={value}>{label}</SelectItem>            ))}          </SelectContent>        </Select>        <FieldError match={!!errors.modality}>{errors.modality}</FieldError>      </Field>      {/* DatePicker is a Popover trigger, not a Base UI form control, so it          registers with nothing. Its ARIA is wired by hand. */}      <Field name="date" invalid={!!errors.date}>        <FieldLabel htmlFor="scan-date">Scan date</FieldLabel>        <DatePicker          id="scan-date"          className="w-full"          value={date}          onValueChange={value => setDate(value as Date | null)}          invalid={!!errors.date}          aria-invalid={!!errors.date || undefined}          aria-describedby={            errors.date ? 'scan-date-error scan-date-hint' : 'scan-date-hint'          }        />        <FieldDescription id="scan-date-hint">Today or later.</FieldDescription>        <FieldError id="scan-date-error" match={!!errors.date}>{errors.date}</FieldError>      </Field>      <Button type="submit" size="sm" className="self-start">        Book scan      </Button>    </form>  )}

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.

ComponentReadWriteValue
InputvalueonValueChange(value, details)string
SelectvalueonValueChange(value)string | string[] | null
DatePickervalueonValueChange(date)Date | DateRange | null
TimeFieldvalueonValueChange(time)string | nullHH:mm
Checkbox, SwitchcheckedonCheckedChange(checked)boolean
RadioGroupvalueonValueChange(value)string
TextareavalueonChange(event)string — a plain <textarea>
FileTrigger, FileDropzoneonSelect(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.onBlur has to be passed on for mode: 'onBlur' and onTouched to do anything. DatePicker, Select and TimeField all forward onBlur to the element that receives focus.
  • field.value is undefined before the first change. DatePicker and TimeField treat undefined as "uncontrolled" and start managing their own state, so the field stops responding to reset(). Give the form a defaultValues with an explicit null, or coalesce as above.
  • Do not put a Controller around Input. It works, but register is 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 error

The 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 digits, from the patient wristband.

Required for MR. Implants, devices, pregnancy.

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Checkbox } from '@/registry/qure/ui/checkbox'import { DatePicker } from '@/registry/qure/ui/date-picker'import {  Field, FieldControl, FieldDescription, FieldError, FieldLabel,} from '@/registry/qure/ui/field'import { Input } from '@/registry/qure/ui/input'import {  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/registry/qure/ui/select'import { Textarea } from '@/registry/qure/ui/textarea'import { TimeField } from '@/registry/qure/ui/time-field'const modalities = { ct: 'CT', mr: 'MR', us: 'Ultrasound' }type Values = {  mrn: string  modality: string | null  date: Date | null  time: string | null  notes: string  consent: boolean}type Errors = Partial<Record<keyof Values, string>>/** One validator for the whole form, so the rules live in one readable place. */function validate(values: Values): Errors {  const errors: Errors = {}  if (!/^\d{7}$/.test(values.mrn)) errors.mrn = 'An MRN is seven digits.'  if (!values.modality) errors.modality = 'Choose a modality.'  if (!values.date) errors.date = 'Choose a date.'  if (!values.time) errors.time = 'Choose a time, such as 09:30.'  if (values.modality === 'mr' && values.notes.trim().length < 10) {    errors.notes = 'MR needs a safety note — implants, devices, pregnancy.'  }  if (!values.consent) errors.consent = 'Consent must be recorded before booking.'  return errors}export default function FormAppointment() {  const [values, setValues] = React.useState<Values>({    mrn: '', modality: null, date: null, time: null, notes: '', consent: false,  })  const [errors, setErrors] = React.useState<Errors>({})  const [booked, setBooked] = React.useState(false)  /* Re-validate on change only once a submit has already failed. Telling     somebody their MRN is too short while they are still typing the first     digit is not help. */  function set<K extends keyof Values>(key: K, value: Values[K]) {    const next = { ...values, [key]: value }    setValues(next)    setBooked(false)    if (Object.keys(errors).length) setErrors(validate(next))  }  function submit(event: React.FormEvent) {    event.preventDefault()    const found = validate(values)    setErrors(found)    setBooked(Object.keys(found).length === 0)  }  return (    <form noValidate onSubmit={submit} className="flex w-full max-w-md flex-col gap-5">      <Field name="mrn" invalid={!!errors.mrn}>        <FieldLabel>MRN</FieldLabel>        <Input          placeholder="0000000"          inputMode="numeric"          value={values.mrn}          onValueChange={value => set('mrn', value)}        />        <FieldDescription>Seven digits, from the patient wristband.</FieldDescription>        <FieldError match={!!errors.mrn}>{errors.mrn}</FieldError>      </Field>      <Field name="modality" invalid={!!errors.modality}>        <FieldLabel>Modality</FieldLabel>        <Select          items={modalities}          value={values.modality}          onValueChange={value => set('modality', value as string)}        >          <SelectTrigger className="w-full">            <SelectValue placeholder="Not set" />          </SelectTrigger>          <SelectContent>            {Object.entries(modalities).map(([value, label]) => (              <SelectItem key={value} value={value}>{label}</SelectItem>            ))}          </SelectContent>        </Select>        <FieldError match={!!errors.modality}>{errors.modality}</FieldError>      </Field>      <div className="flex flex-col gap-5 sm:flex-row">        <Field name="date" invalid={!!errors.date}>          <FieldLabel htmlFor="appt-date">Date</FieldLabel>          <DatePicker            id="appt-date"            className="w-full"            value={values.date}            onValueChange={value => set('date', value as Date | null)}            invalid={!!errors.date}            aria-invalid={!!errors.date || undefined}            aria-describedby={errors.date ? 'appt-date-error' : undefined}          />          <FieldError id="appt-date-error" match={!!errors.date}>{errors.date}</FieldError>        </Field>        <Field name="time" invalid={!!errors.time}>          <FieldLabel>Time</FieldLabel>          <TimeField            className="w-full"            value={values.time}            onValueChange={value => set('time', value)}            step={15}            min="08:00"            max="18:00"            invalid={!!errors.time}          />          <FieldError match={!!errors.time}>{errors.time}</FieldError>        </Field>      </div>      <Field name="notes" invalid={!!errors.notes}>        <FieldLabel>Safety notes</FieldLabel>        <FieldControl          size={null}          render={<Textarea rows={3} />}          value={values.notes}          onChange={event => set('notes', event.currentTarget.value)}        />        <FieldDescription>Required for MR. Implants, devices, pregnancy.</FieldDescription>        <FieldError match={!!errors.notes}>{errors.notes}</FieldError>      </Field>      <Field name="consent" invalid={!!errors.consent}>        <FieldLabel className="gap-2">          <Checkbox            checked={values.consent}            onCheckedChange={checked => set('consent', checked)}          />          Consent recorded        </FieldLabel>        <FieldError match={!!errors.consent}>{errors.consent}</FieldError>      </Field>      <div className="flex items-center gap-3">        <Button type="submit" size="sm">Book appointment</Button>        {booked ? (          <span className="text-label-base text-muted-foreground">            Booked for {values.date?.toLocaleDateString()} at {values.time}.          </span>        ) : null}      </div>    </form>  )}

Seven fields, one validator, no library. Worth noticing:

  • The validator is one function over the whole value object, not a validate per 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.
  • TimeField keeps unreadable input on screen rather than reverting it, and marks itself invalid. Type hh into it and tab away.

Choosing an approach

SituationReach for
A handful of fields, values only needed at submitBase UI Form + Field validate
One field validating as you leave itField validationMode="onBlur"
A server rejected the submit<Form errors={{ mrn: 'No such patient' }}>
Values needed while typing — live filters, dependent fieldsuseState + a validator function
Cross-field rules, or more than about ten fieldsreact-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 reloadA 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 Form component. Base UI's is thirty lines of behaviour we have no reason to restyle, and importing it from @base-ui/react/form is one line. A wrapper here would only add a name.
  • FormField / FormItem / FormMessage wrappers. They exist in shadcn to bridge react-hook-form's context to a label and a message. Field already is that bridge, minus the library.
  • A schema layer. zod belongs to the application, not to a component registry.

On this page