React Hook Form
Build forms by letting React Hook Form manage values, validation, submission,
and field state. Use Takeoff Field components for the visible field anatomy:
label, description, invalid state, and error message.
Demo
The form below shows the field structure and error display behavior.
Approach
Use React Hook Form's useForm hook to create the form instance. Use
Controller when a component is composed or controlled, because the render
function gives you a clear place to map form state into Takeoff props.
fieldcontains bindings such asname,value,onChange,onBlur, andref.fieldStatecontains the field's validation state, includinginvalidanderror.Field invalid={fieldState.invalid}drives the field-level visual state.aria-invalid={fieldState.invalid}belongs on the interactive control.
Anatomy
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid} required>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field
{...field}
aria-invalid={fieldState.invalid}
placeholder="Change return flight"
autoComplete="off"
/>
</Input>
<Field.Description>Use a short, specific title.</Field.Description>
{fieldState.invalid ? (
<Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage>
) : null}
</Field>
)}
/>
Form
Create a Form Schema
Define the shape and validation messages for the form. The example below uses Zod, but the field anatomy stays the same with any resolver supported by React Hook Form.
import * as z from 'zod';
const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
Set Up the Form
Create the form instance with useForm, pass the schema resolver, and define
default values for every field.
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
type FormValues = z.infer<typeof formSchema>;
export function BookingRequestForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
function onSubmit(values: FormValues) {
console.log(values);
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Build the form here */}
</form>
);
}
Build the Form
Use Controller for each field and map fieldState into Field.
import { Controller } from 'react-hook-form';
import { Button, Checkbox, Field, Input, Select } from '@takeoff-ui/react-spar';
export function BookingRequestForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
return (
<form
onSubmit={form.handleSubmit(values => console.log(values))}
className="grid gap-4"
>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid} required>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field
{...field}
aria-invalid={fieldState.invalid}
autoComplete="off"
/>
</Input>
{fieldState.invalid ? (
<Field.ErrorMessage>
{fieldState.error?.message}
</Field.ErrorMessage>
) : null}
</Field>
)}
/>
<Controller
name="cabin"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid} required>
<Field.Label>Cabin</Field.Label>
<Select
name={field.name}
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
>
<Select.Trigger
placeholder="Select cabin"
aria-invalid={fieldState.invalid}
/>
<Select.Content>
<Select.Item value="economy" label="Economy">
Economy
</Select.Item>
<Select.Item value="business" label="Business">
Business
</Select.Item>
</Select.Content>
</Select>
{fieldState.invalid ? (
<Field.ErrorMessage>
{fieldState.error?.message}
</Field.ErrorMessage>
) : null}
</Field>
)}
/>
<Controller
name="accepted"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid} required>
<Checkbox
checked={field.value}
onChange={field.onChange}
name={field.name}
aria-invalid={fieldState.invalid}
>
<Checkbox.Indicator />
</Checkbox>
<Field.Label>I accept the booking terms</Field.Label>
{fieldState.invalid ? (
<Field.ErrorMessage>
{fieldState.error?.message}
</Field.ErrorMessage>
) : null}
</Field>
)}
/>
<Button type="submit">Submit</Button>
</form>
);
}
Done
When the user submits, React Hook Form validates the data and calls your submit
handler with the validated values. Invalid fields receive fieldState.invalid,
and each field can render its own Field.ErrorMessage.
Validation
Client-Side Validation
Schema validation lives in the resolver option. Field rendering does not need
to know where the error came from; it only reads fieldState.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
Validation Modes
React Hook Form supports different validation modes:
| Mode | Description |
|---|---|
onSubmit | Validate on submit. This is the default. |
onBlur | Validate when a field loses focus. |
onChange | Validate on every change. |
onTouched | Validate after the first blur, then on every change. |
all | Validate on both blur and change. |
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onBlur',
});
Displaying Errors
Display errors next to the field they belong to. For styling and accessibility:
- Pass
fieldState.invalidtoField invalid. - Pass
fieldState.invalidto the interactive control witharia-invalid. - Render
Field.ErrorMessageonly when the field is invalid.
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid}>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field {...field} aria-invalid={fieldState.invalid} />
</Input>
{fieldState.invalid ? (
<Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage>
) : null}
</Field>
)}
/>
Working with Different Field Types
The field anatomy stays the same. Only the value mapping changes:
| Component | Mapping |
|---|---|
Input.Field | Spread field, then add aria-invalid={fieldState.invalid}. |
Select | Use value={field.value} and onChange={field.onChange} on Select; put aria-invalid on Select.Trigger. |
Checkbox | Use checked={field.value} and onChange={field.onChange}. |
Switch | Use checked={field.value} and onChange={field.onChange}. |
Radio | Use value={field.value} and onChange={field.onChange} on the Radio root. |
<Controller
name="cabin"
control={form.control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid}>
<Field.Label>Cabin</Field.Label>
<Select
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
>
<Select.Trigger
placeholder="Select cabin"
aria-invalid={fieldState.invalid}
/>
<Select.Content>
<Select.Item value="economy" label="Economy">
Economy
</Select.Item>
<Select.Item value="business" label="Business">
Business
</Select.Item>
</Select.Content>
</Select>
{fieldState.invalid ? (
<Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage>
) : null}
</Field>
)}
/>
Array Fields
Use useFieldArray when the form needs a repeatable set of fields such as
passengers, baggage items, or contact methods. Keep the array state in React
Hook Form, then render one Field per item.
The live demo below mirrors the same add, remove, reset, and submit flow as the code sample.