react-hook-form Guide 2026: Controller, Register, Watch Patterns
Master react-hook-form v7 in 2026: register, Controller, watch, and validation patterns that actually hold up in production codebases.
Why react-hook-form Is Still the Right Call
React form libraries come and go, but react-hook-form has stayed the dominant pick because it actually solves the two hard problems at once: performance and DX. Most alternatives force you to choose. You want controlled inputs and you pay with re-renders. You want uncontrolled and you lose integration with UI libraries. react-hook-form found the middle path back in v6 and the team has sharpened it ever since.
As of v7.51 (released early 2026), the bundle sits at around 9.3 KB gzipped. That's smaller than a single Framer Motion animation variant. For form-heavy apps — dashboards, multi-step wizards, checkout flows — that difference compounds fast. Worth noting: switching from Formik to react-hook-form on a medium-sized SaaS cut one team's form-related re-renders by roughly 70% in their own profiling. Your numbers will vary, but the direction won't.
In practice, the library's uncontrolled-by-default model means your inputs talk to the DOM directly via ref. The form values live outside React state entirely until you need them — at submit time or on demand via watch. This is why typing into a field doesn't trigger a re-render of your parent component. Simple idea, massive win at scale.
That said, uncontrolled has limits. Custom components from Radix UI, shadcn/ui, MUI, or your own design system often don't expose a ref you can forward cleanly. That's where Controller comes in, and we'll cover it in depth. For now — if you haven't tried react-hook-form yet, you're writing forms the hard way.
register: The Foundation You Can't Skip
register is the heart of the library. Call it on a native input and you're done — validation, value extraction, error tracking all wire up automatically. The API is dead simple on purpose.
import { useForm } from 'react-hook-form';
type LoginForm = {
email: string;
password: string;
};
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>();
const onSubmit = (data: LoginForm) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Enter a valid email',
},
})}
placeholder="Email"
/>
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Min 8 characters' },
})}
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign in</button>
</form>
);
}The spread {...register('email', options)} attaches name, ref, onChange, and onBlur to the input. You don't touch any of those manually. Validation runs on blur by default, though you can switch to onChange or onSubmit via the mode option in useForm.
One thing that trips people up: the ref from register uses a callback ref internally. If you also need your own ref to that input — say, to call .focus() on it — you can't just pass a second ref prop. Instead, use register's returned ref callback alongside useCallback. Alternatively, swap to Controller and you get explicit control. Quick aside: setting mode: 'onBlur' on useForm is usually the right call for login/signup forms — validate after the user leaves a field, not while they're still typing.
Nested field paths work out of the box too. register('address.city') maps to { address: { city: value } } in your submitted data. No extra config needed.
Controller: Wrapping UI Library Components
Here's where most tutorials fail you. They show register on a plain <input>, then wave their hands when you ask about Radix Select, a date picker, or a custom slider. Controller is the answer — it's a wrapper that bridges react-hook-form's internal state with any component that uses value/onChange instead of a forwarded ref.
import { useForm, Controller } from 'react-hook-form';
import * as Select from '@radix-ui/react-select';
type ProfileForm = {
username: string;
country: string;
};
export function ProfileForm() {
const { control, handleSubmit, formState: { errors } } = useForm<ProfileForm>();
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="country"
control={control}
rules={{ required: 'Pick a country' }}
render={({ field }) => (
<Select.Root
value={field.value}
onValueChange={field.onChange}
>
<Select.Trigger>
<Select.Value placeholder="Select country" />
</Select.Trigger>
<Select.Content>
<Select.Item value="us">United States</Select.Item>
<Select.Item value="uk">United Kingdom</Select.Item>
<Select.Item value="fr">France</Select.Item>
</Select.Content>
</Select.Root>
)}
/>
{errors.country && <p>{errors.country.message}</p>}
</form>
);
}The field object from render gives you value, onChange, onBlur, name, and ref. Most controlled components only need value and onChange. But pass onBlur too if you want blur-mode validation to fire correctly — easy to forget, painful to debug when your errors show up late.
Honestly, Controller adds a tiny bit of boilerplate but it's the right abstraction. Don't fight it. If you're building a design system of your own — say, styled components that sit on top of something like Empire UI's glassmorphism or glassmorphism components — wrapping them with Controller means your form logic stays completely separate from your visual layer. That's the right separation.
One more thing — useController is the hook version of Controller. Same behavior, better composability when you're building reusable form field components. Use it when your custom field is its own component file and you want to keep JSX clean.
watch, getValues, and Derived State
What if you need to read form values before submit? Maybe a field conditionally shows based on another field's value. Maybe you want a live character count. watch handles this — but you need to understand when it re-renders and when it doesn't.
const { register, watch, formState: { errors } } = useForm<{
plan: 'free' | 'pro';
teamSize: number;
}>();
const plan = watch('plan');
return (
<form>
<select {...register('plan')}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
{plan === 'pro' && (
<input
type="number"
{...register('teamSize', {
required: 'Team size required for Pro',
min: { value: 2, message: 'Minimum 2 seats' },
})}
placeholder="Team size"
/>
)}
</form>
);Calling watch('plan') subscribes your component to re-renders whenever that field changes. That's fine for one or two fields. But if you call watch() with no arguments to get all values, you're subscribing to every keystroke in the form — which wrecks performance on large forms. Use getValues() instead when you need values at a point in time without triggering re-renders, like inside an event handler or a useEffect.
// Good — no subscription, reads once
const handleBlur = () => {
const current = getValues('email');
validateEmailAsync(current);
};
// Careful — subscribes to ALL fields
const allValues = watch();For truly complex derived state — cross-field validation, computed totals, dependent dropdowns — consider pulling that logic into a useEffect that reads from watch on specific fields only. You keep reactivity where you need it and avoid the performance trap. That said, Zod's superRefine with resolver often eliminates the need for derived validation entirely. More on that below.
Zod Integration: The Pattern That Actually Scales
Built-in validation rules (required, minLength, pattern) are fine for simple forms. The moment you need cross-field validation, conditional requirements, or reusable schema logic, you want a schema library. Zod with @hookform/resolvers is the combination most teams land on in 2026.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z
.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 chars'),
confirmPassword: z.string(),
role: z.enum(['admin', 'member', 'viewer']),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignupForm = z.infer<typeof signupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
defaultValues: { role: 'member' },
});
return (
<form onSubmit={handleSubmit(async (data) => {
await createUser(data);
})}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Creating…' : 'Sign up'}
</button>
</form>
);
}The z.infer<typeof signupSchema> trick is the real win here. Your TypeScript types and your runtime validation schema are one source of truth. You can't have a field that validates but doesn't type-check, or vice versa. This killed an entire category of bugs on forms I've maintained.
Look, the .refine() method for cross-field validation is what makes Zod indispensable. You can express "confirmPassword must match password" or "endDate must be after startDate" or "at least one checkbox must be checked" — all in the schema, all with proper error path mapping so the error lands on the right field. And since the schema is just a plain object, you can unit-test it without rendering a single component.
Worth noting: @hookform/resolvers v3.9+ also supports Yup, Valibot, and Arktype if your team is already committed to one of those. The swap is a one-line change to useForm. For new projects in 2026 though, Zod is the default choice for most TypeScript stacks.
useFieldArray: Dynamic Lists Done Right
How do you handle a form where users can add and remove rows — like a list of team members, product variants, or task items? useFieldArray is the answer. Rolling your own with useState and index-based keys is a trap you don't want to fall into.
import { useForm, useFieldArray } from 'react-hook-form';
type TeamForm = {
members: { name: string; email: string }[];
};
export function TeamForm() {
const { register, control, handleSubmit } = useForm<TeamForm>({
defaultValues: { members: [{ name: '', email: '' }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'members',
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`members.${index}.name`, { required: true })}
placeholder="Name"
/>
<input
{...register(`members.${index}.email`, { required: true })}
placeholder="Email"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '' })}
>
Add member
</button>
<button type="submit">Save team</button>
</form>
);
}The field.id from useFieldArray is critical — always use it as the React key, never the index. When you remove item at index 1 from a 3-item list, items shift. If your keys were indexes, React re-uses the wrong DOM nodes and your inputs show stale values. This is a silent, nasty bug. field.id is a stable UUID generated by react-hook-form that survives reorders and removals.
You also get insert, swap, move, prepend, and replace from the hook. Drag-to-reorder lists? Use move. Reset the whole array from a server response? Use replace. The API covers almost every dynamic list pattern without you writing a single custom reducer.
For styling these dynamic rows, if you're using a design system built on modern aesthetics — say, components that follow neobrutalism or claymorphism patterns — useFieldArray pairs cleanly with any layout approach since it's purely logic, no opinions on markup.
Production Patterns: Reset, DefaultValues, and Async Forms
A few patterns separate production-grade forms from tutorial-level ones. The first is defaultValues. Always pass them, even if they're empty strings. Without defaultValues, your fields are uncontrolled until the first render and you'll see React warnings about switching between controlled and uncontrolled.
// Async default values from an API
export function EditProfileForm({ userId }: { userId: string }) {
const { data: profile } = useQuery(['profile', userId], fetchProfile);
const { register, handleSubmit, reset } = useForm<ProfileForm>({
defaultValues: {
name: '',
bio: '',
},
});
// Populate form once data loads
useEffect(() => {
if (profile) reset(profile);
}, [profile, reset]);
return <form onSubmit={handleSubmit(updateProfile)}>...</form>;
}The reset(values) pattern is the correct way to populate a form from async data. Don't pass async defaultValues directly to useForm — they're only read once on mount, so if your data arrives late, the form stays empty. Reset after the data loads. Include reset in your useEffect dependency array — it's stable (memoized by the library), so it won't cause infinite loops.
For submit state, formState.isSubmitting is your friend. It's true while your async submit handler is running and automatically returns to false when it resolves or rejects. Disable your submit button on it, show a spinner, do whatever you need. You don't need useState for loading state on form submission — the library tracks it for you.
One more thing — setError lets you push server-side errors back into the form after submission. If your API returns { field: 'email', message: 'Already in use' }, you can call setError('email', { message: 'Already in use' }) and the error displays exactly where it belongs, right under the email field, without any extra error state management. That's the kind of thing that looks obvious in hindsight but takes teams weeks to land on.
If your forms also need polished visual treatment, the glassmorphism generator is a good starting point for input and card styling that pairs well with any react-hook-form setup — the logic layer is completely decoupled from CSS, so drop in whatever aesthetic you're working with.
FAQ
Use register for native HTML inputs (input, select, textarea) — it's faster and simpler. Switch to Controller when you're wrapping a custom component or UI library component that doesn't expose a ref, like Radix UI primitives, MUI components, or a date picker.
It can. Calling watch('fieldName') subscribes your component to re-renders on that specific field — usually fine. Calling watch() with no arguments subscribes to every field change, which tanks performance on large forms. Use getValues() when you just need a snapshot without reactivity.
Call setError('fieldName', { type: 'server', message: 'Error from API' }) inside your submit handler's catch block. The error maps to the correct field automatically. Use the field name 'root' for non-field-specific errors like auth failures.
The form logic itself (useForm, Controller, etc.) needs to run in a Client Component — it relies on hooks and browser events. Server Actions work great as the submit target via handleSubmit, but keep your form component files marked with 'use client'.