Skip to content

react-hook-form

Every form control binds to react-hook-form one of two ways, and which one depends on where the control keeps its value:

  • On a native element — an <input>, <textarea> or <select> — bind it with register(), as you would the element itself.
  • In React — a <button role="checkbox">, a list of options, a pair of thumbs — bind it with Controller. register() fails quietly on these.
ControlBindingValue
Input, Textarearegister()string
Selectregister()string, '' for the “Choose…” option
Sliderregister(name, { valueAsNumber: true })number
DatePickerregister()'YYYY-MM-DD', or 'YYYY-MM-DDTHH:mm' with withTime; ''
DateRangePickerregister() for each end, in startInputProps and endInputPropstwo strings, as DatePicker’s
CheckboxControllerboolean, or 'indeterminate'
A group of CheckboxesController, one fieldstring[]
SwitchControllerboolean
RadioGroupControllerstring | null
ComboboxControllerthe item or null; an array with multiple
RangeSliderController, one field[number, number]
Calendar, RangeCalendarController'YYYY-MM-DD'; { start, end } for a range
OTP inputControllerstring

Every control in one sign-up form: each wrapped in a Field for its label, hint and error, bound with register() or Controller as the rest of this page explains, and validated by react-hook-form.

We send the receipt here.

Optional. Up to 160 characters.

Bengaluru
Berlin
Lagos
London
New York
São Paulo
Tokyo
Plan
Interests

From 1 to 10.

We sent it to your email. The right one here is 123456.

import { useState } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import {
Button,
Checkbox,
Combobox,
Field,
OtpInput,
RadioGroup,
Select,
Slider,
Switch,
Textarea,
} from '@arun-dev/ui';
type Values = {
name: string;
email: string;
bio: string;
country: string;
city: string | null;
plan: string | null;
interests: string[];
seats: number;
updates: boolean;
code: string;
terms: boolean;
};
const defaultValues: Values = {
name: '',
email: '',
bio: '',
country: '',
city: null,
plan: null,
interests: [],
seats: 2,
updates: true,
code: '',
terms: false,
};
const cities = ['Bengaluru', 'Berlin', 'Lagos', 'London', 'New York', 'São Paulo', 'Tokyo'];
const interests = [
{ value: 'design', label: 'Design' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'product', label: 'Product' },
];
const stack = { display: 'grid', gap: 'var(--space-md)', maxInlineSize: '28rem' };
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
/** Stands in for your server: only 123456 is the code it sent. */
async function checkCode(code: string) {
await new Promise((resolve) => setTimeout(resolve, 400));
return code === '123456';
}
export default function ReactHookFormSignup() {
const [submitted, setSubmitted] = useState<Values | null>(null);
// The default modes: errors appear on submit, then update as you type. Validating on blur as
// well would move the button under a pointer whose press blurred the field above it.
const {
register,
control,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<Values>({ defaultValues });
// useWatch rather than watch(), which React Compiler cannot memoize around.
const plan = useWatch({ control, name: 'plan' });
const seats = useWatch({ control, name: 'seats' });
async function onSubmit(values: Values) {
setSubmitted(null);
// A rule only the server can check, reported back onto its field.
if (!(await checkCode(values.code))) {
setError('code', { message: 'That code is not right. Try 123456.' }, { shouldFocus: true });
return;
}
setSubmitted(values);
}
return (
<form noValidate onSubmit={handleSubmit(onSubmit)} style={stack}>
{/* Input, Textarea, Select and Slider keep their value on the element: register(). */}
<Field.Root invalid={!!errors.name} required>
<Field.Label>Full name</Field.Label>
<Field.Control
autoComplete="name"
{...register('name', {
required: 'Enter your name.',
minLength: { value: 2, message: 'Use at least 2 characters.' },
})}
/>
<Field.Error>{errors.name?.message}</Field.Error>
</Field.Root>
<Field.Root invalid={!!errors.email} required>
<Field.Label>Email</Field.Label>
<Field.Control
type="email"
autoComplete="email"
{...register('email', {
required: 'Enter your email.',
pattern: {
value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
message: 'Enter an email like ada@example.com.',
},
})}
/>
<Field.Description>We send the receipt here.</Field.Description>
<Field.Error>{errors.email?.message}</Field.Error>
</Field.Root>
<Field.Root invalid={!!errors.bio}>
<Field.Label>About you</Field.Label>
<Field.Control
render={<Textarea rows={3} />}
{...register('bio', {
maxLength: { value: 160, message: 'Keep it under 160 characters.' },
})}
/>
<Field.Description>Optional. Up to 160 characters.</Field.Description>
<Field.Error>{errors.bio?.message}</Field.Error>
</Field.Root>
<Field.Root invalid={!!errors.country} required>
<Field.Label>Country</Field.Label>
<Field.Control
render={
<Select>
<option value="">Choose…</option>
<option value="in">India</option>
<option value="de">Germany</option>
<option value="ng">Nigeria</option>
<option value="gb">United Kingdom</option>
<option value="us">United States</option>
</Select>
}
{...register('country', { required: 'Choose a country.' })}
/>
<Field.Error>{errors.country?.message}</Field.Error>
</Field.Root>
{/* Combobox, RadioGroup, Checkbox, Switch and OtpInput keep their value in React:
Controller. */}
<Controller
control={control}
name="city"
rules={{ required: 'Choose a city from the list.' }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error} required>
<Field.Label>City</Field.Label>
<Combobox.Root items={cities} value={field.value} onValueChange={field.onChange}>
<Field.Control
render={<Combobox.Input placeholder="Search cities…" />}
ref={field.ref}
onBlur={field.onBlur}
/>
<Combobox.Popup>
<Combobox.Empty>No cities found.</Combobox.Empty>
<Combobox.List>
{(city: string) => (
<Combobox.Item key={city} value={city}>
{city}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
{/* A group of controls is a fieldset with a legend, not a Field: one Field names one
control. The error is tied to the group with aria-describedby. */}
<Controller
control={control}
name="plan"
// deps: changing the plan re-checks seats, whose rule reads it.
rules={{ required: 'Choose a plan.', deps: ['seats'] }}
render={({ field, fieldState }) => (
<RadioGroup.Root
render={<fieldset />}
className="fieldset"
name={field.name}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={fieldState.error ? true : undefined}
aria-describedby={fieldState.error ? 'signup-plan-error' : undefined}
>
<legend className="fieldset-legend">Plan</legend>
{['Free', 'Pro', 'Team'].map((label) => (
<label key={label} style={row}>
<RadioGroup.Item value={label.toLowerCase()} />
{label}
</label>
))}
{fieldState.error && (
<p id="signup-plan-error" className="fieldset-error">
{fieldState.error.message}
</p>
)}
</RadioGroup.Root>
)}
/>
<Controller
control={control}
name="interests"
rules={{ validate: (picked) => picked.length > 0 || 'Pick at least one.' }}
render={({ field, fieldState }) => (
<fieldset
className="fieldset"
aria-invalid={fieldState.error ? true : undefined}
aria-describedby={fieldState.error ? 'signup-interests-error' : undefined}
>
<legend className="fieldset-legend">Interests</legend>
{interests.map(({ value, label }) => (
<label key={value} htmlFor={`signup-interest-${value}`} style={row}>
{/* One array value, shared by the group: each box adds or removes itself. */}
<Checkbox.Root
id={`signup-interest-${value}`}
checked={field.value.includes(value)}
onCheckedChange={(checked) =>
field.onChange(
checked === true
? [...field.value, value]
: field.value.filter((picked) => picked !== value),
)
}
onBlur={field.onBlur}
>
<Checkbox.Indicator />
</Checkbox.Root>
{label}
</label>
))}
{fieldState.error && (
<p id="signup-interests-error" className="fieldset-error">
{fieldState.error.message}
</p>
)}
</fieldset>
)}
/>
<Field.Root invalid={!!errors.seats}>
<Field.Label>
Seats <output>{seats}</output>
</Field.Label>
<Field.Control
render={<Slider min={1} max={10} />}
{...register('seats', {
valueAsNumber: true,
// Rules can read other fields: the free plan stops at 3 seats.
validate: (value, values) =>
values.plan !== 'free' || value <= 3 || 'The free plan has up to 3 seats.',
})}
/>
<Field.Description>
{plan === 'free' ? 'The free plan has up to 3 seats.' : 'From 1 to 10.'}
</Field.Description>
<Field.Error>{errors.seats?.message}</Field.Error>
</Field.Root>
<Controller
control={control}
name="updates"
render={({ field }) => (
<label htmlFor="signup-updates" style={row}>
<Switch.Root
id="signup-updates"
ref={field.ref}
checked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
>
<Switch.Thumb />
</Switch.Root>
Email me product updates
</label>
)}
/>
<Controller
control={control}
name="code"
rules={{
pattern: { value: /^\d{6}$/, message: 'Enter all 6 digits.' },
required: 'Enter the code.',
}}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error} required>
<Field.Label>Verification code</Field.Label>
<Field.Control
render={
<OtpInput
length={6}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
/>
}
/>
<Field.Description>
We sent it to your email. The right one here is 123456.
</Field.Description>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
<Controller
control={control}
name="terms"
rules={{ validate: (accepted) => accepted || 'Accept the terms to continue.' }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error}>
<div style={row}>
<Field.Control
render={
<Checkbox.Root
ref={field.ref}
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
onBlur={field.onBlur}
>
<Checkbox.Indicator />
</Checkbox.Root>
}
/>
<Field.Label>I accept the terms</Field.Label>
</div>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary" pending={isSubmitting}>
Create account
</Button>
<Button
type="button"
onClick={() => {
reset();
setSubmitted(null);
}}
>
Reset
</Button>
</div>
<output aria-live="polite">
{submitted && <pre className="text-size-sm">{JSON.stringify(submitted, null, 2)}</pre>}
</output>
</form>
);
}

Things to try:

  • Submit it empty. Every rule fails at once, each message appears under its control, and focus moves to the first one.
  • Fix a field. After a submit, each error updates as you type and disappears once the rule passes.
  • Pick the Free plan with more than 3 seats. The seats rule reads another field, and deps re-checks it when the plan changes.
  • Enter a wrong code. The form submits, the server check fails, and setError puts its message on the code and focuses it. 123456 is the right one.
  • Reset. reset() returns every control to its default, the Controller-bound ones included.

Radio group and the interests are groups of controls, so they are a <fieldset> with a <legend> rather than a Field, which names one control, styled with the fieldset classes. Their error is tied to the group with aria-describedby.

Each form below is complete: copy it, rename the fields, and keep the controls you need. Submit one to see exactly what handleSubmit receives.

Text, a list, a number and dates: register()

Section titled “Text, a list, a number and dates: register()”

Input, Textarea, Select, Slider, DatePicker and DateRangePicker. Field.Control renders the control and takes the spread, so the label, the description and the invalid state are tied to the native element itself.

Optional. Leave both empty, or pick a start and an end.

import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Button, DatePicker, DateRangePicker, Field, Select, Slider, Textarea } from '@arun-dev/ui';
// Controls whose value lives on a native element bind with register(), both ways: the form reads
// what is typed, and setValue() and reset() write to the element.
type Values = {
title: string;
notes: string;
priority: string;
effort: number;
due: string;
from: string;
to: string;
};
const defaultValues: Values = {
title: '',
notes: '',
priority: '',
effort: 3,
due: '',
from: '',
to: '',
};
export default function ReactHookFormRegister() {
const [saved, setSaved] = useState<Values | null>(null);
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<Values>({ defaultValues });
const effort = useWatch({ control, name: 'effort' });
return (
<form
noValidate
onSubmit={handleSubmit(setSaved)}
style={{ display: 'grid', gap: 'var(--space-md)', maxInlineSize: '28rem' }}
>
{/* Input: Field.Control renders one by default. */}
<Field.Root invalid={!!errors.title} required>
<Field.Label>Title</Field.Label>
<Field.Control {...register('title', { required: 'Give the task a title.' })} />
<Field.Error>{errors.title?.message}</Field.Error>
</Field.Root>
<Field.Root invalid={!!errors.notes}>
<Field.Label>Notes</Field.Label>
<Field.Control
render={<Textarea rows={3} autoResize />}
{...register('notes', { maxLength: { value: 200, message: 'Up to 200 characters.' } })}
/>
<Field.Error>{errors.notes?.message}</Field.Error>
</Field.Root>
{/* An empty first option and a required rule: the empty string counts as missing. */}
<Field.Root invalid={!!errors.priority} required>
<Field.Label>Priority</Field.Label>
<Field.Control
render={
<Select>
<option value="">Choose…</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
}
{...register('priority', { required: 'Choose a priority.' })}
/>
<Field.Error>{errors.priority?.message}</Field.Error>
</Field.Root>
{/* valueAsNumber: a range input's value is a string until told otherwise. */}
<Field.Root>
<Field.Label>
Effort <output>{effort}</output>
</Field.Label>
<Field.Control
render={<Slider min={1} max={5} />}
{...register('effort', { valueAsNumber: true })}
/>
</Field.Root>
{/* A date is a native date input: YYYY-MM-DD, or '' when empty. */}
<Field.Root invalid={!!errors.due} required>
<Field.Label>Due date</Field.Label>
<Field.Control
render={<DatePicker />}
{...register('due', { required: 'Pick a due date.' })}
/>
<Field.Error>{errors.due?.message}</Field.Error>
</Field.Root>
{/* A range is two native inputs, so two fields: one register() for each end. */}
<Field.Root invalid={!!errors.from || !!errors.to}>
<Field.Label>Blocked out</Field.Label>
<Field.Control
render={
<DateRangePicker
startInputProps={register('from', {
validate: (from, values) => !values.to || !!from || 'Pick a start date too.',
})}
endInputProps={register('to')}
/>
}
/>
<Field.Description>
Optional. Leave both empty, or pick a start and an end.
</Field.Description>
<Field.Error>{errors.from?.message}</Field.Error>
</Field.Root>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary">
Save
</Button>
<Button
type="button"
onClick={() => {
reset();
setSaved(null);
}}
>
Reset
</Button>
</div>
<output aria-live="polite">
{saved && <pre className="text-size-sm">{JSON.stringify(saved, null, 2)}</pre>}
</output>
</form>
);
}
  • A Slider’s value is a string on the element; valueAsNumber makes it a number in the form.
  • A DateRangePicker is two native inputs, so it is two fields, from and to. A rule on one can read the other through its second argument.

Yes or no, one of a few, several of a few: Controller

Section titled “Yes or no, one of a few, several of a few: Controller”

Checkbox, a group of Checkboxes, Switch and RadioGroup. Pass field.value and field.onChange under the control’s own names — checked and onCheckedChange, value and onValueChange — and field.onBlur.

Visibility
Notify me by
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button, Checkbox, Field, RadioGroup, Switch } from '@arun-dev/ui';
// Controls whose value lives in React bind with Controller: value and onChange (or the
// control's own names for them) from `field`, and the error from `fieldState`.
type Values = {
visibility: string | null;
channels: string[];
reminders: boolean;
terms: boolean;
};
const defaultValues: Values = { visibility: null, channels: [], reminders: true, terms: false };
const channels = [
{ value: 'email', label: 'Email' },
{ value: 'sms', label: 'SMS' },
{ value: 'push', label: 'Push' },
];
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
export default function ReactHookFormChoices() {
const [saved, setSaved] = useState<Values | null>(null);
const { control, handleSubmit, reset } = useForm<Values>({ defaultValues });
return (
<form
noValidate
onSubmit={handleSubmit(setSaved)}
style={{ display: 'grid', gap: 'var(--space-md)', maxInlineSize: '28rem' }}
>
{/* One of a few: a RadioGroup rendered as the fieldset, so its legend names the group. */}
<Controller
control={control}
name="visibility"
rules={{ required: 'Choose who can see it.' }}
render={({ field, fieldState }) => (
<RadioGroup.Root
render={<fieldset />}
className="fieldset"
name={field.name}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
aria-describedby={fieldState.error ? 'choices-visibility-error' : undefined}
>
<legend className="fieldset-legend">Visibility</legend>
{['Private', 'Team', 'Public'].map((label) => (
<label key={label} style={row}>
<RadioGroup.Item value={label.toLowerCase()} />
{label}
</label>
))}
{fieldState.error && (
<p id="choices-visibility-error" className="fieldset-error">
{fieldState.error.message}
</p>
)}
</RadioGroup.Root>
)}
/>
{/* Several of a few: one array field, each Checkbox adding or removing its own value. */}
<Controller
control={control}
name="channels"
rules={{ validate: (picked) => picked.length > 0 || 'Pick at least one channel.' }}
render={({ field, fieldState }) => (
<fieldset
className="fieldset"
aria-describedby={fieldState.error ? 'choices-channels-error' : undefined}
>
<legend className="fieldset-legend">Notify me by</legend>
{channels.map(({ value, label }) => (
<label key={value} htmlFor={`choices-channel-${value}`} style={row}>
<Checkbox.Root
id={`choices-channel-${value}`}
checked={field.value.includes(value)}
onCheckedChange={(checked) =>
field.onChange(
checked === true
? [...field.value, value]
: field.value.filter((picked) => picked !== value),
)
}
onBlur={field.onBlur}
>
<Checkbox.Indicator />
</Checkbox.Root>
{label}
</label>
))}
{fieldState.error && (
<p id="choices-channels-error" className="fieldset-error">
{fieldState.error.message}
</p>
)}
</fieldset>
)}
/>
{/* On or off, taking effect as a setting: a Switch. */}
<Controller
control={control}
name="reminders"
render={({ field }) => (
<label htmlFor="choices-reminders" style={row}>
<Switch.Root
id="choices-reminders"
ref={field.ref}
checked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
>
<Switch.Thumb />
</Switch.Root>
Send reminders
</label>
)}
/>
{/* Must be ticked: validate, not required, and the third state resolved to a boolean. */}
<Controller
control={control}
name="terms"
rules={{ validate: (accepted) => accepted || 'Accept the terms to continue.' }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error}>
<div style={row}>
<Field.Control
render={
<Checkbox.Root
ref={field.ref}
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
onBlur={field.onBlur}
>
<Checkbox.Indicator />
</Checkbox.Root>
}
/>
<Field.Label>I accept the terms</Field.Label>
</div>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary">
Save
</Button>
<Button
type="button"
onClick={() => {
reset();
setSaved(null);
}}
>
Reset
</Button>
</div>
<output aria-live="polite">
{saved && <pre className="text-size-sm">{JSON.stringify(saved, null, 2)}</pre>}
</output>
</form>
);
}
  • A group of Checkboxes is one array field: each box adds or removes its own value.
  • “Accept the terms” uses validate, not required, and stores checked === true, so the field is a plain boolean (see Checkbox’s third state).

Search, a range, a calendar and a code: Controller

Section titled “Search, a range, a calendar and a code: Controller”

Combobox, alone and with multiple, RangeSlider, Calendar and OtpInput.

Ada
Grace
Katherine
Linus
Margaret
Tim
Ada
Grace
Katherine
Linus
Margaret
Tim

Up to 3.

Working hours 9:00 – 17:00
Delivery day
October 2026
October 2026
SunMonTueWedThuFriSat

Optional.

import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button, Calendar, Combobox, Field, OtpInput, RangeSlider } from '@arun-dev/ui';
// Pickers keep their value in React, so they bind with Controller too: field.value to `value`,
// field.onChange to `onValueChange`.
type Values = {
owner: string | null;
reviewers: string[];
hours: readonly [number, number];
delivery: string | null;
pin: string;
};
const defaultValues: Values = {
owner: null,
reviewers: [],
hours: [9, 17],
delivery: null,
pin: '',
};
const people = ['Ada', 'Grace', 'Katherine', 'Linus', 'Margaret', 'Tim'];
export default function ReactHookFormPickers() {
const [saved, setSaved] = useState<Values | null>(null);
const { control, handleSubmit, reset } = useForm<Values>({ defaultValues });
return (
<form
noValidate
onSubmit={handleSubmit(setSaved)}
style={{ display: 'grid', gap: 'var(--space-md)', maxInlineSize: '28rem' }}
>
{/* One from a long list: the Input is the Field's control, so the label names it. */}
<Controller
control={control}
name="owner"
rules={{ required: 'Choose an owner.' }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error} required>
<Field.Label>Owner</Field.Label>
<Combobox.Root items={people} value={field.value} onValueChange={field.onChange}>
<Field.Control
render={<Combobox.Input placeholder="Search people…" />}
ref={field.ref}
onBlur={field.onBlur}
/>
<Combobox.Popup>
<Combobox.Empty>No one found.</Combobox.Empty>
<Combobox.List>
{(person: string) => (
<Combobox.Item key={person} value={person}>
{person}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
{/* Several from a long list: `multiple`, and the value is an array. */}
<Controller
control={control}
name="reviewers"
rules={{ validate: (picked) => picked.length <= 3 || 'Up to 3 reviewers.' }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error}>
<Field.Label>Reviewers</Field.Label>
<Combobox.Root
items={people}
multiple
value={field.value}
onValueChange={field.onChange}
>
<Field.Control
render={<Combobox.Input placeholder="Search people…" />}
ref={field.ref}
onBlur={field.onBlur}
/>
<Combobox.Popup>
<Combobox.Empty>No one found.</Combobox.Empty>
<Combobox.List>
{(person: string) => (
<Combobox.Item key={person} value={person}>
{person}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
<Field.Description>Up to 3.</Field.Description>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
{/* A from–to range: one field holding the pair. RangeSlider and Calendar are groups of
controls, not one, so the label and error are tied to them by id, with Field's classes. */}
<Controller
control={control}
name="hours"
rules={{ validate: ([from, to]) => to - from >= 2 || 'At least 2 hours.' }}
render={({ field, fieldState }) => (
<div className="field">
<span id="pickers-hours" className="field-label">
Working hours{' '}
<output>
{field.value[0]}:00 – {field.value[1]}:00
</output>
</span>
<RangeSlider.Root
aria-labelledby="pickers-hours"
aria-describedby={fieldState.error ? 'pickers-hours-error' : undefined}
min={0}
max={24}
value={field.value}
onValueChange={field.onChange}
>
<RangeSlider.StartInput aria-label="Start hour" onBlur={field.onBlur} />
<RangeSlider.EndInput aria-label="End hour" onBlur={field.onBlur} />
</RangeSlider.Root>
{fieldState.error && (
<p id="pickers-hours-error" className="field-error">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
{/* A day picked on a calendar shown in the page: YYYY-MM-DD. */}
<Controller
control={control}
name="delivery"
rules={{ required: 'Pick a delivery day.' }}
render={({ field, fieldState }) => (
<div className="field">
<span id="pickers-delivery" className="field-label">
Delivery day
</span>
<Calendar
aria-labelledby="pickers-delivery"
aria-describedby={fieldState.error ? 'pickers-delivery-error' : undefined}
value={field.value}
onValueChange={field.onChange}
/>
{fieldState.error && (
<p id="pickers-delivery-error" className="field-error">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
{/* A code: OtpInput, the whole code as one string. */}
<Controller
control={control}
name="pin"
rules={{ pattern: { value: /^\d{4}$/, message: 'Enter all 4 digits.' } }}
render={({ field, fieldState }) => (
<Field.Root invalid={!!fieldState.error}>
<Field.Label>Door PIN</Field.Label>
<Field.Control
render={
<OtpInput
length={4}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
/>
}
/>
<Field.Description>Optional.</Field.Description>
<Field.Error>{fieldState.error?.message}</Field.Error>
</Field.Root>
)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary">
Save
</Button>
<Button
type="button"
onClick={() => {
reset();
setSaved(null);
}}
>
Reset
</Button>
</div>
<output aria-live="polite">
{saved && <pre className="text-size-sm">{JSON.stringify(saved, null, 2)}</pre>}
</output>
</form>
);
}
  • Combobox’s Combobox.Input is the Field’s control, so pass it field.ref and field.onBlur; the value goes on Combobox.Root.
  • RangeSlider’s value is the pair, one field, and a rule reads both ends.
  • RangeSlider and Calendar are groups of controls rather than one, so their label and error are tied to them by id, with Field’s classes (.field, .field-label, .field-error) for the look.
import { Controller, useForm } from 'react-hook-form';
import { Checkbox } from '@arun-dev/ui';
const { control, handleSubmit } = useForm({ defaultValues: { terms: false } });
<Controller
control={control}
name="terms"
render={({ field }) => (
<Checkbox.Root
checked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
aria-label="Accept terms"
>
<Checkbox.Indicator />
</Checkbox.Root>
)}
/>;

Switch is the same, with Switch.Root and Switch.Thumb. useController works equally well if you prefer a hook to a render prop.

register() fails silently

Spreading register('terms') onto Checkbox.Root or Switch.Root type-checks, renders, and looks right on screen. The control flips when clicked — and the form keeps its default value. No error, no warning. useWatch reports the stale value forever, and the form submits something the user never chose.

register() returns props for a native input:

propgoes toresult
refthe <button>it has no .checked, so there is nothing to read
onChangethe <button>a <button> never fires a change event
namethe hidden inputworks, and is the only part that does

The control’s state lives in React, not on an element react-hook-form can inspect. That is the cost of the element choice described in Components, and Controller is what it is for.

A radio group’s items are real <input type="radio"> elements, so register() gets further there. It reads them correctly: a click reaches the form, and the form submits it. It still cannot write to them. setValue and reset set .checked on the DOM directly, and the group’s value never hears of it. The radio shows checked while its data-checked stays on the old one, and a later render puts the old one back.

That is a property of the value living in React rather than of the element, so it would not change with a different element either. Bind the group with Controller, passing field.value to value and field.onChange to onValueChange:

<Controller
control={control}
name="plan"
render={({ field }) => (
<RadioGroup.Root
name={field.name}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
aria-label="Plan"
>
<label>
<RadioGroup.Item value="free" />
Free
</label>
</RadioGroup.Root>
)}
/>

All of this is covered by a contract spec in @arun-dev/ui, so it stays true:

CheckboxRadio groupSwitch
Controller / useController✅✅✅
useWatch and watch✅✅✅
setValue drives the control✅✅✅
reset() drives the control✅✅✅
Native form.reset() keeps the form in step✅✅✅
rules validation, errors block submit✅✅✅
formState.isDirty✅✅✅
disabled cannot move the value✅✅✅
register()❌❌ reads only❌

The native reset row is worth a moment. A <button type="reset"> resets the hidden input by itself, and the control returns to the state it mounted with — but react-hook-form only hears about that because the change is reported through onCheckedChange (onValueChange for a radio group), the same callback a click goes through. Without that it would show one value and hold another.

name is redundant under Controller, and not harmless. name makes the control render a hidden input for native form submission. Under Controller, react-hook-form owns the value, and the hidden input will also appear in a native FormData read of the form. Pass name for native submission, or use Controller, not both. A radio group is the exception: it always has a name, generated if you omit it, so pass field.name and a native read at least sees the field’s own.

Do not rely on field.ref. It resolves to the <button>, not the hidden input, so react-hook-form’s focus-on-error lands on the right element but any value read through it is undefined. Passing it is harmless; trusting it is not.

required treats false as missing. rules={{ required: 'Required' }} on an unchecked control blocks submit, which is what you want for “accept the terms” and wrong for a genuine yes/no. Use validate when false is a legitimate answer.

checked is boolean | 'indeterminate', and that value round-trips through react-hook-form unchanged — useWatch reports 'indeterminate', and handleSubmit hands you the literal string. Correct, and almost never what an API wants. Resolve it at the edge:

handleSubmit((data) => save({ terms: data.terms === true }));

Input, Textarea and Select: use register()

Section titled “Input, Textarea and Select: use register()”

Input keeps nothing in React. The value lives on the <input>, which is exactly what register() reads and writes, so spread it straight on:

const { register, formState } = useForm({ defaultValues: { email: '' } });
<label htmlFor="email">Email</label>
<Input
id="email"
type="email"
aria-invalid={formState.errors.email ? true : undefined}
{...register('email', { required: true })}
/>

ref, name, onChange and onBlur all land on the <input>, never on the box, whether or not the input has slots. setValue, reset(), validation and isDirty work with no extra wiring. Set aria-invalid from formState.errors to turn the border to the error colour.

Textarea binds the same way, with the same spread. It has no box, so everything lands on the <textarea> directly.

Select binds the same way too; like Input, ref, name and the handlers land on the <select>, not its box. For a “Choose…” prompt, add an <option value=""> and a required rule: react-hook-form treats the empty string as missing.

Slider, DatePicker and DateRangePicker bind the same way, since each is native inputs too: a Slider with valueAsNumber, a DateRangePicker with one register() in each of startInputProps and endInputProps. The first form to copy has all of them.

Controller works too, if a field needs a transformed value, but it isn’t required.

Button defaults to type="button", so a button inside a form does nothing until told otherwise. Give the submit button type="submit" for handleSubmit to run.

While an async onSubmit runs, pass formState.isSubmitting to pending: the button is disabled, so a second press or Enter in a field cannot submit again, and a Spinner shows over its label.

<Button type="submit" variant="primary" pending={formState.isSubmitting}>
Save
</Button>

For a reset, call react-hook-form’s reset() from a type="button", as the forms above do, so the form state and every control return together. An error only the server can find goes back on its field with setError(name, { message }, { shouldFocus: true }), as the whole form does for the code.