Blog
3 min read

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).
  • handleSubmit validates first and only calls onSubmit with clean data.
  • valueAsNumber converts the string to a number.

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 / valueAsDate and 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 — use useWatch on 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