Forms with React Hook Form: register, Zod Validation, Controller and Field Arrays
Key takeaways
How React Hook Form keeps forms fast by avoiding re-renders: registering inputs, validation rules and Zod schemas, watching values, wrapping custom components with Controller, and field arrays in a complex form.
Why React Hook Form works the way it does
The classic React form keeps every field in state: value={email} plus onChange={e => setEmail(e.target.value)}. That is simple, but each keystroke re-renders the component that owns the state, and in a large form that is usually the whole form. With a few dozen fields, custom inputs, and validation that runs on change, typing starts to feel sluggish on slower devices.
React Hook Form takes the opposite approach. By default it leaves inputs uncontrolled: the DOM holds the current value, and the library keeps a reference to each input through the ref that register returns. It reads values when it needs them (on submit, on blur, or when validation runs) and only re-renders when something the component actually reads changes, such as an error message appearing. The trade-off is that you give up the “form state is just React state” mental model. Values do not live in a useState you can print, and the parts of the API that feel strange at first (watch, Controller, formState being a proxy) all exist to bridge that gap.
It is a good fit for forms with many fields, forms where validation rules are shared with the server through a schema, and teams already using Zod. For a two-field search box, a pair of useState calls is still perfectly fine and easier for newcomers to read. With React 19, simple forms that post straight to a server action also do not need a form library at all.
Installation and a basic form
npm install react-hook-form
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
register('email', rules) returns an object with name, ref, onChange, and onBlur. Spreading it onto the input connects the DOM element to the form; there is no value prop, which is the point. handleSubmit(onSubmit) returns an event handler that prevents the default submit, runs validation, and calls onSubmit with typed values only if everything passes. If validation fails, it focuses the first invalid field (controlled by shouldFocusError) and never calls your function.
formState is a proxy. React Hook Form tracks which properties you read during render and only re-renders the component when those change. Destructuring errors subscribes to errors; if you never read isDirty, the form does not re-render when the form becomes dirty. The common confusion is a formState.isValid that seems stuck: it is only computed when you read it, and in onSubmit mode it only updates after the first submit unless you configure mode: 'onChange' or 'onBlur'.
Validation timing is set with mode (when the first validation happens) and reValidateMode (when a field that already has an error is checked again). The default, mode: 'onSubmit' with reValidateMode: 'onChange', is a good default for users: they do not see errors while typing their first attempt, but an error disappears as soon as they fix it.
Validation rules
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
<input
{...register('age', {
required: true,
valueAsNumber: true,
min: { value: 18, message: 'Must be 18+' },
max: { value: 120, message: 'Invalid age' },
})}
type="number"
/>
Built-in rules mirror HTML attributes (required, min, max, minLength, maxLength, pattern) but are enforced by the library, so they produce messages you control and work the same in every browser. Note valueAsNumber on the age field. Without it the value is the string "25", and min/max still work because they compare numerically, but your submitted data has a string where your type says number. With it, an empty field becomes NaN, which required treats as missing.
Custom and async validation
<input
{...register('username', {
required: true,
validate: async (value) => {
const exists = await checkUsernameExists(value);
return !exists || 'Username already taken';
},
})}
/>
validate returns true for valid, or a string (or false) for invalid. An async validator is convenient, but it runs every time the field is validated. In onChange mode that means a network request per keystroke, and responses can arrive out of order. For uniqueness checks I prefer mode: 'onBlur' for that field, or validating on the server when the form is submitted and mapping the server response back with setError('username', { message: '...' }). The server has to check anyway, since a name can be taken between the client check and the submit.
Zod integration
npm install @hookform/resolvers zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'Must be 18+'),
});
type FormData = z.infer<typeof schema>;
export default function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
A resolver replaces the per-field rules with one schema. The main reasons to use it are that the same schema can validate the request body on the server, and that z.infer gives you the form’s TypeScript type without writing it twice. Rules passed to register are ignored when a resolver is set, so do not mix the two and expect both to run.
Two pitfalls come up with Zod specifically. First, without valueAsNumber the age field sends a string, and Zod reports Expected number, received string, which confuses users because the field visibly contains a number. Either convert in register or use z.coerce.number(). Second, schemas with .transform() or .default() have different input and output types. useForm<FormData> with FormData = z.infer<...> types the fields as the output, which is wrong for what the user types. Recent versions of React Hook Form accept separate type parameters for this, useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>, so the fields use the input type and onSubmit receives the transformed output.
Cross-field rules, like “password and confirmation must match”, belong in .refine() on the object with a path: ['confirmPassword'], so the error attaches to the right field instead of to the form root.
watch, useWatch and setValue
const { register, watch } = useForm();
const email = watch('email');
const allValues = watch();
useEffect(() => {
console.log('Email changed:', email);
}, [email]);
watch('email') subscribes the component to that field and re-renders it on each change. That is what you want for a field that controls what else is shown, such as a “company name” input that appears only when “account type” is “business”. watch() with no argument subscribes to every field, and at the top of a large form it re-renders the entire form on each keystroke, turning React Hook Form back into a controlled-inputs library with extra steps.
When only a small part of the UI depends on a value, move the subscription down into a child component with useWatch({ control, name: 'email' }). Only that child re-renders. For side effects, the callback form watch((values, { name }) => { ... }) inside a useEffect (returning the subscription’s unsubscribe) reacts to changes without re-rendering at all; newer versions also expose subscribe for the same purpose.
const { register, setValue } = useForm();
const handleReset = () => {
setValue('email', '');
setValue('password', '');
};
setValue updates a field programmatically, but by default it does not validate or mark the field as dirty or touched. Pass { shouldValidate: true, shouldDirty: true } when the change should count as user input. To clear the whole form, reset() is usually what you want instead: it restores defaultValues and clears errors and dirty state in one call, while calling setValue for each field leaves the old errors visible.
The problem I run into most often with this part of the API is loading an edit form from an API. useForm({ defaultValues: user }) looks right, but defaultValues is read once on the first render, when user is still undefined. The form stays empty after the data arrives. The fix is either to render the form only after the data has loaded, or to call reset(user) in an effect when it arrives, or to pass values: user, which keeps the form updated from an external source.
Controller for custom components
import { Controller } from 'react-hook-form';
import Select from 'react-select';
const options = [
{ value: 'us', label: 'United States' },
{ value: 'kr', label: 'South Korea' },
];
<Controller
name="country"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Select
ref={field.ref}
onBlur={field.onBlur}
options={options}
value={options.find((o) => o.value === field.value) ?? null}
onChange={(option) => field.onChange(option?.value)}
/>
)}
/>
register depends on a DOM input it can attach a ref to and read .value from. react-select, most date pickers, rich-text editors, and many UI-kit components render something else and report changes through their own onChange signature. Spreading register onto them either does nothing or stores the wrong thing, with no error. Controller handles those by making that one field controlled: it gives you field.value, field.onChange, field.onBlur, and field.ref, and re-renders only that component when its value changes.
The example maps between the form value and the component’s own format. Spreading {...field} directly onto react-select is a common shortcut, but then the form stores the whole { value, label } option object instead of 'us', and your schema of z.string() fails on submit with a type error that is hard to trace back to the select. Converting in value and onChange keeps the form data clean.
If you forget to provide a default value for a Controller field, React warns A component is changing an uncontrolled input to be controlled the first time the user picks something, because the value went from undefined to a real value. Set every Controller field in defaultValues, using null or '' for “nothing selected”.
Dynamic fields with useFieldArray
import { useForm, useFieldArray } from 'react-hook-form';
export default function DynamicForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
items: [{ name: '', quantity: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 0 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
Three rules keep field arrays working. Use field.id as the React key, not index: the library generates a stable id per row, and keying by index makes React reuse the wrong DOM inputs after a remove, so the values appear to shift into the wrong rows. Pass a complete object to append (every field of the row), because missing keys have no default value to reset to. And do not call append and remove in the same render tick or from inside a useEffect that also depends on fields; each action updates the array, and chaining them causes stale indices.
The field array needs its own entry in defaultValues. Starting with items: [] is fine; leaving items out entirely makes TypeScript infer the wrong type for register paths and gives you undefined rows. Also note that the type="button" on Add and Remove is not decoration: a <button> inside a form defaults to type="submit", so leaving it out makes every “Remove” click submit the form.
A larger form with nested objects
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
personalInfo: z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
}),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
}),
});
type FormData = z.infer<typeof schema>;
export default function ComplexForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
preferences: { newsletter: false, notifications: false },
},
});
const onSubmit = async (data: FormData) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Personal Info</h2>
<input {...register('personalInfo.firstName')} />
{errors.personalInfo?.firstName && <span>{errors.personalInfo.firstName.message}</span>}
<input {...register('personalInfo.lastName')} />
<input {...register('personalInfo.email')} />
<h2>Address</h2>
<input {...register('address.street')} />
<input {...register('address.city')} />
<input {...register('address.zipCode')} />
<h2>Preferences</h2>
<label>
<input type="checkbox" {...register('preferences.newsletter')} />
Newsletter
</label>
<label>
<input type="checkbox" {...register('preferences.notifications')} />
Notifications
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Dotted names like personalInfo.firstName build nested objects in the submitted data, and errors follow the same shape, which is why the error check uses optional chaining. isSubmitting stays true while an async onSubmit is pending, so disabling the button prevents double submission without extra state.
Every field in the schema needs a matching input or a default value. If the notifications checkbox were left out and had no default, z.boolean() would reject the undefined value, the form could never submit, and the error would sit on a field with no input to display it. When a form “does nothing” on submit, log errors or pass an onInvalid callback as the second argument to handleSubmit: it is almost always a schema field that has no visible error message.
Errors thrown inside onSubmit are not turned into field errors. Catch the API failure yourself and call setError('root.serverError', { message }) to show a form-level message, or setError('personalInfo.email', ...) when the server tells you which field was wrong.
React Hook Form compared with the alternatives
Formik keeps values in React state, which makes the model easy to understand and debug, but every change re-renders the form. It has also seen few releases in recent years, which is worth weighing for a new project. TanStack Form is newer, fully type-safe, and framework-agnostic; it is controlled by design but uses fine-grained subscriptions to avoid the re-render problem. Plain React state or React 19 form actions are enough for small forms, especially when validation happens on the server.
React Hook Form’s weak spots are the ones described above: the uncontrolled model means custom components need Controller, programmatic updates need the right flags, and it is easy to reintroduce re-renders with a top-level watch(). If most of your form is custom components rather than native inputs, you will write Controller everywhere, and much of the uncontrolled advantage is gone.
Related Articles
Frequently Asked Questions (FAQ)
Q. Why are my defaultValues ignored when the data arrives from an API?
A. useForm reads defaultValues once, on the first render, and caches them. If the data loads later, call reset(data) when it arrives, or pass it through the values option.
Q. Can I use it with shadcn/ui?
A. Yes. shadcn/ui’s Form components are thin wrappers around React Hook Form’s FormProvider, Controller, and useFormContext, so everything in this article applies.
Q. Why does my number field arrive as a string?
A. HTML inputs always hold strings. Use valueAsNumber: true in register or z.coerce.number() in the schema, and remember that an empty number field becomes NaN.
Q. When should I use register and when do I need Controller?
A. register works for native inputs (and components that forward a ref to one), because React Hook Form reads their values directly from the DOM without re-rendering. Components like react-select or many UI-kit date pickers do not expose a native input, so spreading register onto them silently fails to track the value. Wrap those in Controller (or useController), which gives you field.value and field.onChange to wire up as a controlled component.