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 withregister(), as you would the element itself. - In React — a
<button role="checkbox">, a list of options, a pair of thumbs — bind it withController.register()fails quietly on these.
| Control | Binding | Value |
|---|---|---|
| Input, Textarea | register() | string |
| Select | register() | string, '' for the “Choose…” option |
| Slider | register(name, { valueAsNumber: true }) | number |
| DatePicker | register() | 'YYYY-MM-DD', or 'YYYY-MM-DDTHH:mm' with withTime; '' |
| DateRangePicker | register() for each end, in startInputProps and endInputProps | two strings, as DatePicker’s |
| Checkbox | Controller | boolean, or 'indeterminate' |
| A group of Checkboxes | Controller, one field | string[] |
| Switch | Controller | boolean |
| RadioGroup | Controller | string | null |
| Combobox | Controller | the item or null; an array with multiple |
| RangeSlider | Controller, one field | [number, number] |
| Calendar, RangeCalendar | Controller | 'YYYY-MM-DD'; { start, end } for a range |
| OTP input | Controller | string |
A whole form
Section titled “A whole form”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.
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
depsre-checks it when the plan changes. - Enter a wrong code. The form submits, the server check fails, and
setErrorputs 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.
A form to copy, for each kind of control
Section titled “A form to copy, for each kind of control”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.
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;
valueAsNumbermakes it a number in the form. - A DateRangePicker is two native inputs, so it is two fields,
fromandto. 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.
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, notrequired, and storeschecked === true, so the field is a plainboolean(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.
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.Inputis the Field’s control, so pass itfield.refandfield.onBlur; the value goes onCombobox.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.
Use Controller
Section titled “Use Controller”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:
| prop | goes to | result |
|---|---|---|
ref | the <button> | it has no .checked, so there is nothing to read |
onChange | the <button> | a <button> never fires a change event |
name | the hidden input | works, 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.
Radio group: native, and still Controller
Section titled “Radio group: native, and still Controller”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> )}/>What works once it is bound
Section titled “What works once it is bound”All of this is covered by a contract spec in @arun-dev/ui, so it stays true:
| Checkbox | Radio group | Switch | |
|---|---|---|---|
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.
Three things to watch
Section titled “Three things to watch”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.
Checkbox’s third state
Section titled “Checkbox’s third state”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.
Buttons
Section titled “Buttons”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.