Forms
How to compose, validate, and submit forms with OUI — including React Hook Form + Zod.
Forms
This guide covers the cross-cutting patterns for building forms with OUI: composition with React Aria's Form, validation strategies, controlled vs uncontrolled state, React Hook Form + Zod integration, server-side errors, when to drop down to Field primitives, and layout conventions.
For per-component details, see the individual component docs (e.g., TextField, Select).
When to use which field component
| You need… | Use |
|---|---|
| Single-line text | TextField |
| Multi-line text | TextAreaField |
| Numbers with steppers | NumberField |
| Phone numbers | PhoneNumberField |
| Search (with a clear button) | SearchField |
| Dates (keyboard entry) | DateField |
| Dates with a calendar popover | DatePicker |
| Date ranges | DateRangePicker |
| Times | TimeField |
| Single choice from a short list (dropdown) | Select |
| Single choice with type-ahead filtering | ComboBox |
| Multiple choices with filtering | TagField |
| Single choice from a small set (visible) | RadioGroup |
| Multiple toggles (visible) | CheckboxGroup / Checkbox |
| On/off | Toggle |
| File uploads | FileDropzone |
Form composition basics
OUI fields are designed to be wrapped by React Aria's <Form> element. Each field's name prop becomes a key in the submitted FormData. The onSubmit handler receives a standard form-submit event.
import { Button, TextField } from "@opengovsg/oui"import { useState } from "react"import { Form } from "react-aria-components"export const Example = () => { const [submitted, setSubmitted] = useState<string | null>(null) return ( <Form className="flex flex-col gap-6" onSubmit={(e) => { e.preventDefault() const data = Object.fromEntries(new FormData(e.currentTarget)) setSubmitted(JSON.stringify(data, null, 2)) }} > <TextField name="fullName" label="Full name" isRequired /> <Button type="submit">Submit</Button> {submitted && ( <pre className="rounded bg-gray-100 p-3 text-sm">{submitted}</pre> )} </Form> )}Key points:
- Each field needs a
nameto be included in submission. <Form>triggers native HTML validation by default — invalid fields block submission and surface their constraint violations.- Layout is your own — the example uses
flex flex-col gap-6for vertical stacking with consistent spacing.
Validation
OUI inherits React Aria's validation model. There are three layers:
Built-in (native HTML) constraints
Set isRequired, minLength, maxLength, pattern, or type on the field. Errors appear after the user commits a value (on blur) or submits the form.
import { Button, TextField } from "@opengovsg/oui"import { Form } from "react-aria-components"export const Example = () => { return ( <Form className="flex flex-col gap-6" onSubmit={(e) => e.preventDefault()}> <TextField name="email" label="Email" type="email" isRequired description="Errors appear after blur or submit." /> <TextField name="username" label="Username" isRequired minLength={3} maxLength={20} description="3–20 characters." /> <Button type="submit">Submit</Button> </Form> )}Custom per-field validation
The validate prop accepts a function that returns an error message string when invalid, or null / true when valid.
import { Button, TextField } from "@opengovsg/oui"import { Form } from "react-aria-components"export const Example = () => { return ( <Form className="flex flex-col gap-6" onSubmit={(e) => e.preventDefault()}> <TextField name="username" label="Username" isRequired validate={(v) => v.includes(" ") ? "Username cannot contain spaces" : null } /> <Button type="submit">Submit</Button> </Form> )}Realtime validation
For immediate feedback as the user types (e.g., password strength), use isInvalid + errorMessage with controlled state. This bypasses the commit-then-validate flow.
import { Button, TextField } from "@opengovsg/oui"import { useState } from "react"import { Form } from "react-aria-components"export const Example = () => { const [value, setValue] = useState("") const isValid = value.length >= 8 return ( <Form className="flex flex-col gap-6" onSubmit={(e) => e.preventDefault()}> <TextField name="password" label="Password" type="password" value={value} onChange={setValue} isInvalid={value.length > 0 && !isValid} errorMessage="Password must be at least 8 characters" /> <Button type="submit" isDisabled={!isValid}> Submit </Button> </Form> )}Choosing validationBehavior
The validationBehavior prop on <Form> (or on individual fields) controls enforcement:
"native"(default) — Uses native HTML form validation. Invalid fields prevent submission and show browser-default error styling unlessFieldErroris rendered."aria"— Marks invalid fields via ARIA attributes only. Submission is allowed regardless of validity.
Use "aria" when you're integrating with React Hook Form, Zod, or any external validation library that needs to control the submission flow itself. Use "native" when OUI's built-in constraints are sufficient.
Reading and submitting values
For uncontrolled forms, onSubmit receives the raw event; pull FormData from e.currentTarget:
<Form
onSubmit={(e) => {
e.preventDefault()
const data = Object.fromEntries(new FormData(e.currentTarget))
submit(data)
}}
>
...
</Form>For async submissions, manage a submitting state and swap the submit button's children:
import { Button, TextField } from "@opengovsg/oui"import { useState } from "react"import { Form } from "react-aria-components"export const Example = () => { const [isSubmitting, setIsSubmitting] = useState(false) return ( <Form className="flex flex-col gap-6" onSubmit={async (e) => { e.preventDefault() setIsSubmitting(true) await new Promise((r) => setTimeout(r, 1200)) setIsSubmitting(false) }} > <TextField name="message" label="Message" isRequired /> <Button type="submit" isDisabled={isSubmitting}> {isSubmitting ? "Submitting…" : "Submit"} </Button> </Form> )}React Hook Form + Zod
For complex forms, most production OUI applications use React Hook Form with Zod for schema-driven validation. Set validationBehavior="aria" on <Form> so native validation doesn't interfere with RHF's submission flow.
Wrap each OUI field in RHF's Controller, since OUI fields are controlled-friendly but their value shapes vary (string for TextField, key for Select, set of keys for TagField).
import { zodResolver } from "@hookform/resolvers/zod"import { Button, Select, SelectItem, TagField, TextField } from "@opengovsg/oui"import type { Key } from "react-aria-components"import { Form } from "react-aria-components"import { Controller, useForm } from "react-hook-form"import { z } from "zod"const schema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), country: z.string().min(1, "Pick a country"), interests: z.array(z.string()).min(1, "Pick at least one interest"),})type FormValues = z.infer<typeof schema>const COUNTRY_OPTIONS = [ { id: "sg", textValue: "Singapore" }, { id: "my", textValue: "Malaysia" }, { id: "id", textValue: "Indonesia" },]const INTEREST_OPTIONS = [ { id: "tech", textValue: "Technology" }, { id: "design", textValue: "Design" }, { id: "policy", textValue: "Policy" },]export const Example = () => { const { control, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { name: "", country: "", interests: [] }, }) const onSubmit = async (values: FormValues) => { await new Promise((r) => setTimeout(r, 500)) console.log(values) } return ( <Form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)} validationBehavior="aria" > <Controller control={control} name="name" render={({ field, fieldState }) => ( <TextField label="Name" value={field.value} onChange={field.onChange} onBlur={field.onBlur} isInvalid={fieldState.invalid} errorMessage={errors.name?.message} /> )} /> <Controller control={control} name="country" render={({ field, fieldState }) => ( <Select label="Country" value={field.value || null} onChange={(k: Key | null) => field.onChange(k ?? "")} isInvalid={fieldState.invalid} errorMessage={errors.country?.message} > {COUNTRY_OPTIONS.map((option) => ( <SelectItem key={option.id} id={option.id}> {option.textValue} </SelectItem> ))} </Select> )} /> <Controller control={control} name="interests" render={({ field, fieldState }) => ( <TagField label="Interests" defaultItems={INTEREST_OPTIONS} selectedKeys={new Set<Key>(field.value)} onSelectionChange={(keys: Set<Key>) => field.onChange([...keys] as string[]) } isInvalid={fieldState.invalid} errorMessage={errors.interests?.message} /> )} /> <Button type="submit" isDisabled={isSubmitting}> Submit </Button> </Form> )}Notes:
errorMessageaccepts a string OR a render prop. Here we passerrors.<field>?.messagefrom RHF.- For Select, RHF treats the field as a string; OUI's Select uses
value(aKey | null) andonChange. Coerce in both directions. - For TagField, RHF expects
string[]; OUI'sselectedKeysisSet<Key>. Convert at the boundary.
Server-side errors
After submit, set isInvalid and errorMessage on the affected field with the server's response. Render-side error messages compose seamlessly with client-side validation.
import { Button, TextField } from "@opengovsg/oui"import { useState } from "react"import { Form } from "react-aria-components"export const Example = () => { const [serverError, setServerError] = useState<string | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) return ( <Form className="flex flex-col gap-6" validationBehavior="aria" onSubmit={async (e) => { e.preventDefault() setIsSubmitting(true) setServerError(null) await new Promise((r) => setTimeout(r, 500)) // Simulated server error setServerError("This username is already taken") setIsSubmitting(false) }} > <TextField name="username" label="Username" isRequired isInvalid={serverError != null} errorMessage={serverError ?? undefined} /> <Button type="submit" isDisabled={isSubmitting}> Submit </Button> </Form> )}For form-level errors (e.g., "Your session expired"), render an Infobox above the form. Screen readers announce it when it appears if the container has role="alert" or aria-live="polite".
When to drop down to Field primitives
Use the higher-level wrapper components (TextField, Select, etc.) whenever possible — they bundle label, description, error, and accessibility wiring. Drop down to Field primitives when you need:
- An adornment (icon, prefix, suffix) inside the input.
- A custom layout the wrapper doesn't support.
- A field that combines multiple inputs (e.g., a search input next to a button).
import { Description, FieldError, FieldGroup, Label } from "@opengovsg/oui"import { Search } from "lucide-react"import { Input, TextField } from "react-aria-components"export const Example = () => { return ( <TextField className="flex flex-col gap-2" isRequired> <Label>Search</Label> <FieldGroup> <Search className="ml-3 size-4 text-gray-500" aria-hidden /> <Input className="flex-1 bg-transparent px-2 py-1 outline-none" placeholder="Type to search…" /> </FieldGroup> <Description> Use Field primitives when you need an adornment. </Description> <FieldError /> </TextField> )}The Field docs cover the full set of primitives — Label, Description, FieldError, FieldErrorIcon, FieldGroup.
Layout and spacing
OUI fields stack their internal parts (label, input, description, error) with built-in spacing. You're responsible for the spacing between fields.
The convention used in OUI's own examples:
<Form className="flex flex-col gap-6">...</Form>gap-6 (1.5rem) gives consistent vertical rhythm between fields without making the form feel sparse. Adjust for dense forms (try gap-4) or spacious ones (gap-8).
For the submit button, place it at the bottom of the form, full-width on mobile, fixed-width on desktop:
<Button type="submit" className="self-end sm:w-auto">
Submit
</Button>