React Hook Form
useForm, register, handleSubmit, formState, Controller, useFieldArray, the validation modes.
Updated
What React Hook Form is
React Hook Form (RHF) is the most used form library for React. A single hook, useForm, gives you everything you need: registering fields, validation, errors, the submission state.
The secret of its performance: it's built on uncontrolled fields. The values live in the DOM, not in React state → typing does not re-render the whole form. With 50 fields, you feel the difference.
'use client'
import { useForm } from 'react-hook-form'
type Values = { email: string; password: string }
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<Values>({ defaultValues: { email: '', password: '' } })
const onSubmit = async (values: Values) => {
await login(values)
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register('email', { required: 'Email is required' })} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password', { minLength: { value: 8, message: 'At least 8 characters' } })} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<button disabled={isSubmitting}>Log in</button>
</form>
)
}The pieces of useForm
| Piece | What it does |
|---|---|
register('name', rules) |
connects an input: returns name, ref, onChange, onBlur |
handleSubmit(fn) |
validates, then calls fn(values) only if everything is valid |
formState.errors |
the errors per field: errors.email?.message |
formState.isSubmitting |
true while onSubmit (async) runs |
formState.isDirty / dirtyFields |
what changed compared to defaultValues |
formState.isValid |
the form is valid right now |
watch('name') |
subscribes to a value — re-renders when it changes (use sparingly) |
setValue, reset, setError |
control from code: filling in, resetting, an error coming from the server |
When it validates — the mode
useForm({ mode: 'onTouched' })mode |
Validates | The experience |
|---|---|---|
onSubmit (default) |
on submit, then on every change | errors only after the first submit |
onBlur |
when you leave the field | — |
onTouched |
after the first blur, then on every change | the friendliest — doesn't yell while you're still typing |
onChange |
on every keystroke | aggressive, expensive |
Components that aren't native inputs: Controller
register works with <input>, <select>, <textarea>. For custom components (a date picker, a select from a UI library) you use Controller, which turns them into controlled fields:
<Controller
control={control}
name="country"
render={({ field, fieldState }) => <CountrySelect {...field} error={fieldState.error?.message} />}
/>Dynamic lists: useFieldArray
const { fields, append, remove } = useFieldArray({ control, name: 'phones' })
{fields.map((field, i) => (
<div key={field.id}> {/* field.id, not the index */}
<input {...register(`phones.${i}.number`)} />
<button type="button" onClick={() => remove(i)}>Delete</button>
</div>
))}
<button type="button" onClick={() => append({ number: '' })}>Add a phone</button>Validation with a schema
Rules written in register get scattered across the JSX. The recommended way: a Zod schema + zodResolver — the types are inferred from the schema, and the same schema validates on the server too. Details in the lesson on Zod.
const form = useForm({ resolver: zodResolver(loginSchema), mode: 'onTouched' })Common mistakes
watch()without an argument in the main component → re-renders the whole form on every keystroke. UseuseWatch({ name })in a small component.key={index}inuseFieldArray— usefield.id.- Validation only in RHF, without validation on the server.
Summary
useForm→registeron the fields,handleSubmit(onSubmit),formState.errors.- Uncontrolled under the hood → fast;
Controllerfor custom components,useFieldArrayfor lists. mode: 'onTouched'+ a Zod schema withzodResolver.