Skip to main content

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.


// A picker is composed and controlled, so it lives in its own component and
// binds through Controller. \`field.onChange\` goes to the hook, not to
// Input.Field: that is the one channel both halves report through, and it
// carries a Date.
function DepartureField({ field, fieldState }) {
  const picker = useDatePicker({
    defaultValue: field.value,
    onValueChange: field.onChange,
  });

  return (
    <Field invalid={fieldState.invalid} required>
      <Field.Label>Departure</Field.Label>
      <Popover {...picker.popoverProps}>
        <Input>
          {/* onBlur and name are the form's — inputProps carries neither. */}
          <Input.Field
            {...picker.inputProps}
            name={field.name}
            onBlur={field.onBlur}
            aria-invalid={fieldState.invalid}
            placeholder="dd/mm/yyyy"
          />
          <Popover.Trigger aria-label="Select date" classNames={{ root: 'tk-input-action' }}>
            <CalendarIconOutlinedRounded width={20} height={20} />
          </Popover.Trigger>
        </Input>
        <Popover.Content align="end" classNames={{ root: 'tk-datepicker-panel' }}>
          <Calendar {...picker.calendarProps} />
        </Popover.Content>
      </Popover>
      {fieldState.invalid ? <Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage> : <Field.Description>Pick a date or type it.</Field.Description>}
    </Field>
  );
}

function BookingRequestDemo() {
  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.'),
    departure: z.date({ message: 'Select a departure date.' }),
    cabin: z.string().min(1, 'Select a cabin.'),
    accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
  });

  const form = useForm({
    resolver: zodResolver(formSchema),
    // A half-typed date is undefined until it is whole, so onTouched suits a
    // picker better than validating on every keystroke.
    mode: 'onTouched',
    defaultValues: {
      title: '',
      departure: undefined,
      cabin: '',
      accepted: false,
    },
  });

  return (
    <form id="form-rhf-demo" onSubmit={form.handleSubmit(() => {})} className="grid w-full max-w-90 gap-3">
      <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>
            {fieldState.invalid ? (
              <Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage>
            ) : (
              <Field.Description>Use a short, specific title.</Field.Description>
            )}
          </Field>
        )}
      />

      <Controller name="departure" control={form.control} render={DepartureField} />

      <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>
            <div className="flex items-start gap-3">
              <Checkbox checked={field.value} onChange={field.onChange} name={field.name} aria-invalid={fieldState.invalid}>
                <Checkbox.Indicator />
              </Checkbox>
              <div className="grid gap-1">
                <Field.Label>I accept the booking terms</Field.Label>
                {!fieldState.invalid ? (
                  <Field.Description>Required before submission.</Field.Description>
                ) : null}
              </div>
            </div>
            {fieldState.invalid ? <Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage> : null}
          </Field>
        )}
      />

      <div className="flex items-center justify-end gap-3">
        <Button type="button" variant="neutral" appearance="outlined" onClick={() => form.reset()}>
          Reset
        </Button>
        <Button type="submit" form="form-rhf-demo">
          Submit
        </Button>
      </div>
    </form>
  );
}

render(<BookingRequestDemo />);

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.

  • field contains bindings such as name, value, onChange, onBlur, and ref.
  • fieldState contains the field's validation state, including invalid and error.
  • 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:

ModeDescription
onSubmitValidate on submit. This is the default.
onBlurValidate when a field loses focus.
onChangeValidate on every change.
onTouchedValidate after the first blur, then on every change.
allValidate 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.invalid to Field invalid.
  • Pass fieldState.invalid to the interactive control with aria-invalid.
  • Render Field.ErrorMessage only 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:

ComponentMapping
Input.FieldSpread field, then add aria-invalid={fieldState.invalid}.
SelectUse value={field.value} and onChange={field.onChange} on Select; put aria-invalid on Select.Trigger.
CheckboxUse checked={field.value} and onChange={field.onChange}.
SwitchUse checked={field.value} and onChange={field.onChange}.
RadioUse value={field.value} and onChange={field.onChange} on the Radio root.
Date pickerHold useDatePicker in the render function and give it onValueChange={field.onChange}.
<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>
)}
/>

A date picker is composed and controlled, so it takes the same Controller route — never register(), which would bind the DOM input and collect the masked text, while a day picked in the grid dispatches no DOM change event at all. Give field.onChange to the hook rather than to Input.Field: that is the one channel both halves report through, and it carries a Date.

function DateField({ field, fieldState }) {
const picker = useDatePicker({
defaultValue: field.value,
onValueChange: field.onChange,
});

return (
<Field invalid={fieldState.invalid} required>
<Field.Label>Departure</Field.Label>
<Popover {...picker.popoverProps}>
<Input>
<Input.Field
{...picker.inputProps}
name={field.name}
onBlur={field.onBlur}
aria-invalid={fieldState.invalid}
/>
<Popover.Trigger
aria-label="Select date"
classNames={{ root: 'tk-input-action' }}
>
…
</Popover.Trigger>
</Input>
<Popover.Content
align="end"
classNames={{ root: 'tk-datepicker-panel' }}
>
<Calendar {...picker.calendarProps} />
</Popover.Content>
</Popover>
{fieldState.invalid ? (
<Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage>
) : null}
</Field>
);
}

<Controller name="departure" control={form.control} render={DateField} />;

Three things inputProps does not carry, because they belong to the form: onBlur (without field.onBlur the onTouched / onBlur validation modes never fire), name, and a settled value — the hook keeps the text but holds the value at undefined until the date is whole, so mode: 'onTouched' suits it better than 'onChange'. Keep the field in its own component rather than an inline render function: a hook inside render={...} works, but lint rules read it as a conditional hook.

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.


function PassengerArrayDemo() {
  const formSchema = z.object({
    passengers: z
      .array(
        z.object({
          fullName: z.string().min(1, 'Passenger name is required.'),
        }),
      )
      .min(1, 'Add at least one passenger.'),
  });

  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      passengers: [{ fullName: '' }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control: form.control,
    name: 'passengers',
  });

  return (
    <form onSubmit={form.handleSubmit(() => {})} className="grid w-full max-w-90 gap-3">
      {fields.map((item, index) => {
        const canRemove = fields.length > 1;

        return (
          <Controller
            key={item.id}
            name={'passengers.' + index + '.fullName'}
            control={form.control}
            render={({ field, fieldState }) => (
              <Field invalid={fieldState.invalid} required>
                <Field.Label>Passenger {index + 1}</Field.Label>
                <Input>
                  <Input.Field
                    {...field}
                    aria-invalid={fieldState.invalid}
                    autoComplete="name"
                    placeholder="Ada Lovelace"
                  />
                  <Input.Suffix>
                    <button
                      type="button"
                      aria-label={canRemove ? 'Remove passenger ' + (index + 1) : 'At least one passenger is required'}
                      disabled={!canRemove}
                      onClick={() => remove(index)}
                      className="inline-flex h-5 w-5 justify-center"
                    >
                      <CloseIconOutlinedRounded />
                    </button>
                  </Input.Suffix>
                </Input>
                {fieldState.invalid ? <Field.ErrorMessage>{fieldState.error?.message}</Field.ErrorMessage> : null}
              </Field>
            )}
          />
        );
      })}
      <Button
        type="button"
        variant="neutral"
        appearance="outlined"
        onClick={() => append({ fullName: '' })}
        className="w-full"
      >
        Add passenger
      </Button>
   
      <div className="flex w-full justify-end border-t border-gray-300 pt-3">
        <Button type="submit">Save passengers</Button>
        <Button
          type="button"
          variant="neutral"
          appearance="text"
          onClick={() => form.reset({ passengers: [{ fullName: '' }] })}
        >
          Reset
        </Button>
      </div>
    </form>
  );
}

render(<PassengerArrayDemo />);