Use OrganUI's clinical inputs in forms with React Hook Form, Zod and shadcn's Field.
Demo
A pain assessment with a text input, the body map and a textarea. Submit it empty to see the errors. Browser validation is off so the schema's messages show; keep it on in your own forms.
Approach
OrganUI's inputs are controlled components: each takes its value and calls back with the next one. That is the shape React Hook Form's Controller expects, so they sit in a form like any shadcn input.
useFormholds the form's state.Controllerconnects each input to it.- shadcn's
Fieldlays out the label, description and error, and marks the field invalid. - Zod validates on the client through zodResolver.
Install the libraries, then the components the form uses:
bun add react-hook-form @hookform/resolvers zodbunx shadcn@4.22.1 add field input textarea @organui/body-mapAnatomy
The body map's value and onValueChange take the field's value and onChange. Its label names it, so it needs no FieldLabel.
<Controller
name="regions"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<BodyMap
value={field.value}
onValueChange={field.onChange}
label="Where is the pain?"
/>
<FieldDescription>
Select every region that hurts, front and back.
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Schema
The body map's value is an array of region ids, so the schema checks its length.
import * as z from "zod"
const formSchema = z.object({
complaint: z
.string()
.min(3, "Describe the complaint in at least 3 characters.")
.max(60, "Keep the complaint to 60 characters."),
regions: z.array(z.string()).min(1, "Select at least one region."),
notes: z.string().max(200, "Keep the notes to 200 characters."),
})Pass the schema to useForm through the resolver:
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { complaint: "", regions: [], notes: "" },
})Other inputs
The dermatome map holds one level or null; its props are selectedId and onSelectionChange. Validate it with z.string().nullable().refine((level) => level !== null).
<Controller
name="level"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<DermatomeMap
selectedId={field.value}
onSelectionChange={field.onChange}
label="Level of reduced sensation"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Without a library
Give the body map a name and it submits one hidden input per selected region, like checkboxes, so a plain form or a server function reads them with getAll.
<form action={saveAssessment}>
<BodyMap
value={regions}
onValueChange={setRegions}
name="regions"
label="Where is the pain?"
/>
</form>
// In the server function:
const regions = formData.getAll("regions")Accessibility
aria-invalidon inputs anddata-invalidon fields style errors and announce the invalid state.FieldErroris an alert, so screen readers read each new error.- The maps are named groups with their own keyboard model: one tab stop per view, arrow keys between regions.