React Hook Form: Fast, Validated Forms With Less Code
React Hook Form manages form state with uncontrolled inputs, so big forms stay fast and the code stays short. register, handleSubmit, errors, Zod validation with zodResolver, Controller for UI libraries, field arrays, server errors, and using it with Next.js Server Actions.
Forms in React get verbose quickly: a useState per field, onChange handlers, validation, error messages, submit state. React Hook Form cuts most of that, and keeps large forms fast by using uncontrolled inputs — it reads values from the DOM instead of re-rendering on every keystroke. (Uncontrolled vs controlled inputs)
The basics
npm install react-hook-form
import { useForm } from 'react-hook-form'
type FormValues = { email: string; age: number }
export function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>()
const onSubmit = async (data: FormValues) => {
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) })
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <p>{errors.email.message}</p>}
<input type="number" {...register('age', { valueAsNumber: true, min: { value: 18, message: 'Must be 18+' } })} />
{errors.age && <p>{errors.age.message}</p>}
<button disabled={isSubmitting}>Sign up</button>
</form>
)
}
register('email')connects an input (name, ref, onChange, onBlur).handleSubmitvalidates first and only callsonSubmitwith clean data.valueAsNumberconverts the string to a number.
Validation with Zod (recommended)
Define the rules once as a schema and reuse it on the server:
npm install zod @hookform/resolvers
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
const Schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(12, 'At least 12 characters'),
confirm: z.string(),
}).refine(d => d.password === d.confirm, { message: 'Passwords must match', path: ['confirm'] })
type FormValues = z.infer<typeof Schema>
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '', password: '', confirm: '' },
})
The same Schema validates the request on the server — client-side validation is for user experience, server-side is for security. (Form validation explained, Validating input with Zod)
When validation runs
useForm({ mode: 'onTouched' })
onSubmit (default), onBlur, onChange, onTouched (after first blur, then on change — a good balance), or all.
UI libraries: Controller
Components that don't expose a normal input (date pickers, custom selects, some shadcn/ui components) need Controller:
import { Controller } from 'react-hook-form'
<Controller
name="plan"
control={control}
render={({ field }) => <Select value={field.value} onValueChange={field.onChange} />}
/>
shadcn/ui's Form components wrap this pattern for you. (What is shadcn/ui?)
Dynamic lists: useFieldArray
For "add another line item":
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
{fields.map((field, i) => (
<div key={field.id}>
<input {...register(`items.${i}.description`)} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: '' })}>Add item</button>
Use field.id as the key, not the index. (Unique key prop warning)
Showing server errors
When the server rejects something ("email already registered"), put the error on the field:
const res = await fetch(...)
if (res.status === 409) setError('email', { message: 'That email is already registered' })
Editing existing data
Pass defaultValues, or call reset(data) once data loads, so the form starts filled and isDirty works correctly.
With Next.js Server Actions
React Hook Form runs in the browser; Server Actions run on the server. Validate with the shared Zod schema in both, and call the action from onSubmit. For very simple forms, React's built-in useActionState without a form library may be enough. (Next.js Server Actions)
Common mistakes
- Forgetting
valueAsNumber/valueAsDateand getting strings. - No
defaultValues— leading to undefined values and controlled/uncontrolled warnings. - Trusting client validation alone.
- Using
watch()everywhere, which re-renders the form on every change — useuseWatchon specific fields.
EasySpawn runs your React front-end and the API that validates its forms on the same server, so one Zod schema can guard both — with Claude Code to wire them together. See how it works or join the waitlist.
Related: Form Validation Explained · Validating Input With Zod · "A Component Is Changing an Uncontrolled Input" · Next.js Server Actions
Keep reading
The Vercel AI SDK: Streaming Chat and Tool Calls in TypeScript
The AI SDK is a TypeScript toolkit for calling language models from any provider with one API, streaming responses to React with useChat, calling tools, and generating structured data. A working Next.js chat with Claude, tool calling with stopWhen, structured output, provider choice, and production concerns.
useMemo vs useCallback (and React.memo): When Memoization Helps
useMemo caches a computed value; useCallback caches a function; React.memo skips re-rendering a component whose props didn't change. When each actually helps, when it's wasted effort, the dependency array rules, and how the React Compiler now does most of this for you.