TL;DR: React Hook Form is the standard for form management in React. It uses uncontrolled inputs with refs - zero re-renders per keystroke, ~9.4kB, no dependencies. Pair with
@hookform/resolvers+ Zod for end-to-end type-safe validation. Official docs: react-hook-form.com.
What is React Hook Form?
React Hook Form (RHF) is the de-facto standard for form management in React. It uses uncontrolled inputs with a ref-based approach instead of controlled state, which means form fields don't trigger a full re-render tree on every keystroke - a massive performance win for large or complex forms.
It connects directly to Zod, Yup, Joi, and other validation libraries via the @hookform/resolvers package.
When to use it
React Hook Form is the right choice for almost every form. The only exception is extremely simple one-field forms where the overhead isn't worth it.
Choose RHF over Formik when:
- Performance matters (large forms, many fields)
- You prefer hooks over render-props/HOC patterns
- You need Zod/TypeScript-first type inference
The uncontrolled trick that makes it fast
Here's the idea that explains everything else about React Hook Form. Most form libraries store every field's value in React state, so each keystroke updates state and re-renders the form tree. On a login form you'd never notice. On a 40-field settings page, you feel it.
RHF takes the opposite route. It registers each input by handing it a ref and reads values straight from the DOM when you ask for them, usually on submit. Typing into a field changes the DOM, not React state, so the surrounding components don't re-render. The library only triggers a render when something the UI actually depends on changes, like an error message appearing or isDirty flipping. The result is forms that stay snappy no matter how many fields they have.
This design also keeps the dependency footprint tiny. The core is dependency-free and lands around 9kB, which is small for what it does. You only pull in extra weight when you add a resolver and a schema library on top.
Validation strategies that actually scale
RHF can validate in a few different rhythms, and picking the right one changes how a form feels. The mode option controls when validation first runs: onSubmit is the default and the least noisy, onBlur fires when a field loses focus, and onChange validates on every keystroke. After the first error, reValidateMode takes over and defaults to onChange so the message clears as the user fixes things.
My usual setup is onBlur for the first pass with onChange revalidation. It avoids yelling at someone the instant they start typing an email, but corrects them quickly once they've made a mistake. Aggressive onChange from the start tends to feel hostile on signup flows.
For the actual rules, schema validation beats scattering validate functions across fields. A single Zod schema describes the whole form, gives you TypeScript types for free through z.infer, and keeps your validation logic in one readable place. When the backend and frontend share that schema, you stop writing the same rules twice.
Connecting third-party inputs
Native inputs work with register because they accept a ref. The trouble starts with rich components like React Select, a date picker, or an MUI text field, which manage their own internal state and may not forward a ref the way RHF expects. That's what Controller is for. It creates a controlled bridge, handing your component a field object with value, onChange, onBlur, and ref, and syncing it back into the form.
The mistake I see most often is reaching for Controller everywhere, even on plain inputs. Don't. Every Controller-wrapped field is controlled, so it re-renders on change and gives up the performance benefit. Use register for native inputs, save Controller for the components that genuinely need it, and your forms stay fast by default.
Common traps with default values
Set defaultValues up front. If you leave a field undefined on the first render and React later receives a value, you'll get the classic "changing an uncontrolled input to controlled" warning, and reset behavior gets unpredictable. For async data, either pass defaultValues as a function that fetches, or call reset(data) once the data arrives so the whole form snaps to the loaded values cleanly.
Key Features
- Zero re-renders on keystroke - validation runs on blur or submit, not change
- Schema validation -
@hookform/resolversconnects Zod, Yup, Joi, etc. - Field arrays -
useFieldArrayfor dynamic lists of fields - DevTools -
@hookform/devtoolsfor debugging form state - Controller API - wraps 3rd-party inputs (React Select, MUI, etc.)
Installation
npm install react-hook-form
# For Zod validation:
npm install zod @hookform/resolvers
Basic Usage with 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, 'Min 8 characters'),
})
type FormData = z.infer<typeof schema>
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({ resolver: zodResolver(schema) })
return (
<form onSubmit={handleSubmit(async data => {
await login(data)
})}>
<input {...register('email')} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</button>
</form>
)
}
TypeScript Tips
Let Zod infer your form types - never write them manually:
const schema = z.object({
name: z.string().min(1),
age: z.coerce.number().min(0), // coerce converts string inputs to numbers
role: z.enum(['admin', 'user']),
})
type FormData = z.infer<typeof schema> // { name: string; age: number; role: "admin" | "user" }
For 3rd-party inputs (e.g., React Select), use Controller:
import { Controller } from 'react-hook-form'
<Controller
name="country"
control={control}
render={({ field }) => (
<ReactSelect
{...field}
options={countryOptions}
/>
)}
/>
Common Gotchas
defaultValuesmust be set for controlled behaviour - omitting them causes fields to beundefinedon first render, which React treats as uncontrolled to controlled switch warningswatchtriggers re-renders - avoid watching every field in a large form; usegetValues()inside event handlers instead- File inputs -
registerdoesn't work for<input type="file">; useControllerwith manual file handling - Array field performance - use
useFieldArrayinstead ofwatch+ manual array manipulation
Related
- React Select - integrate
<Controller>from React Hook Form to manage select state and validation - React Dropzone - wrap dropzone input with
<Controller>for file upload validation in React Hook Form - React 19 Guide - React 19's
useActionStateand native form Actions are an alternative for server-side form handling - TypeScript Generics Guide -
useForm<T>()is fully generic; strongly-typed forms catch field name errors at compile time