react-hook-form
react-hook-form manages form state in React by registering inputs as mostly uncontrolled fields, so typing in one input does not re-render the whole form. You get validation (native HTML rules or schema libraries like Zod and Yup through resolvers), error tracking, dirty/touched state, dynamic field arrays, and submit handling from one useForm() hook. It has zero runtime dependencies and only React as a peer dependency.
The default React form library in 2026 for good reason: small, fast, and stable across years of 7.x releases. Budget real extra time if your form is mostly controlled UI-kit widgets, and consider skipping it entirely for simple server-action forms.
Use it if
- You build forms with many fields where per-keystroke re-renders of controlled state become a visible performance problem
- You want schema-based validation: official resolvers exist for Zod, Yup, AJV, Superstruct, and Joi via @hookform/resolvers
- Your inputs are mostly native elements (input, select, textarea) where the register() spread pattern is genuinely minimal boilerplate
- Your form logic is moving server-side (Next.js server actions, Remix): native FormData plus server validation may need no client form library at all
- Your UI is mostly controlled component libraries (MUI, Ant, react-select): every such input needs a Controller wrapper, and that boilerplate cancels much of the simplicity you came for
- You need one form library across React, Vue, Solid, or Angular: this is React-only, TanStack Form is framework-agnostic
- You dislike ref-and-proxy magic: form values live outside React state, so debugging why a value did not update means learning the subscription model first
Setup reality
Install is one package with no dependencies beyond the React peer. The real setup cost arrives later: schema validation requires the separate @hookform/resolvers package plus your schema library; every non-native widget needs a Controller with a render prop; and the TypeScript generics for useForm<FormValues> with deeply nested field paths produce long, confusing errors until you learn the shape. Code you find online mixes v6 and v7 register APIs, and the v7 spread form ({...register('name')}) is the only correct one today.
Patterns
Register fields and handle submitbasic-form
import { useForm } from 'react-hook-form';
function App() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('firstName')} />
<input {...register('lastName', { required: true })} />
{errors.lastName && <p>Last name is required.</p>}
<input type="submit" />
</form>
);
}handleSubmit only calls your callback when validation passes; spread register(), do not pass it as a ref.
Validate with a Zod schemaschema-validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
age: z.coerce.number().min(18),
});
const { register, handleSubmit, formState: { errors } } =
useForm({ resolver: zodResolver(schema) });@hookform/resolvers is a separate install; use z.coerce for number inputs because DOM values are always strings.
Wire a controlled UI-kit input with Controllercontrolled-component
import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';
const { control } = useForm();
<Controller
name="flavor"
control={control}
rules={{ required: true }}
render={({ field }) => <Select {...field} options={options} />}
/>Any component that does not expose a native input ref needs Controller; register() alone will silently not track it.
Add and remove rows with useFieldArraydynamic-field-array
import { useForm, useFieldArray } from 'react-hook-form';
const { control, register } = useForm({
defaultValues: { items: [{ name: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>x</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>Use field.id as the key, never the array index, or rows lose their values on remove.
React to a field value changingwatch-values
import { useWatch } from 'react-hook-form';
function ShippingFields({ control }) {
const sameAsBilling = useWatch({ control, name: 'sameAsBilling' });
if (sameAsBilling) return null;
return <AddressFields />;
}useWatch re-renders only the component that calls it; watch() at the form root re-renders the whole form.
Set a value programmatically and reset after submitset-and-reset-values
const { setValue, reset, handleSubmit } = useForm({ defaultValues });
setValue('email', 'a@b.co', { shouldValidate: true, shouldDirty: true });
const onSubmit = async (data) => {
await save(data);
reset(data); // marks the form pristine with the saved values
};setValue does not trigger validation or dirty tracking unless you pass the should* flags.
Load default values from an APIasync-default-values
const { register, formState: { isLoading } } = useForm({
defaultValues: async () => {
const res = await fetch('/api/user');
return res.json();
},
});
if (isLoading) return <Spinner />;formState.isLoading is true only while async defaultValues resolve; fields render empty until then.
Show a server-side error after submitserver-error
const { setError, formState: { errors } } = useForm();
const onSubmit = async (data) => {
const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) });
if (res.status === 409) {
setError('email', { type: 'server', message: 'Email already taken' });
}
};
{errors.email && <p>{errors.email.message}</p>}Errors set with setError on a field are cleared automatically the next time that field passes validation.
Validate on blur instead of on submitvalidation-mode
const form = useForm({
mode: 'onBlur', // first validation pass
reValidateMode: 'onChange', // after a field has errored
});The default mode is onSubmit; mode: 'onChange' validates every keystroke and costs the performance you picked this library for.
Access form methods in deeply nested componentsform-context
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
function Page() {
const methods = useForm();
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInput />
</form>
</FormProvider>
);
}
function NestedInput() {
const { register } = useFormContext();
return <input {...register('bio')} />;
}useFormContext returns null outside a FormProvider; there is no error until you destructure.
Validate one field against anotherdependent-validation
const { register, getValues } = useForm();
<input type="password" {...register('password')} />
<input
type="password"
{...register('confirm', {
validate: (value) =>
value === getValues('password') || 'Passwords do not match',
})}
/>The validate callback also receives all form values as its second argument if you prefer not to call getValues.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| formik | npm | You maintain a codebase already built on it; for new projects its development pace has fallen well behind |
| @tanstack/react-form | npm | You want a headless, framework-agnostic form library with first-class TypeScript inference |