Skip to content

Forms

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.

Pain Assessment
Record where it hurts before the consultation.

Each view is one tab stop. Arrow keys move between regions; Space or Enter selects or clears the focused region.

Front
Back

No regions selected

Select every region that hurts, front and back.

0/200 characters

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.

  • useForm holds the form's state.
  • Controller connects each input to it.
  • shadcn's Field lays 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 zod
bunx shadcn@4.22.1 add field input textarea @organui/body-map

Anatomy

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-invalid on inputs and data-invalid on fields style errors and announce the invalid state.
  • FieldError is 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.