Skip to content
Forms

Hook Form — Component Library Guide

Performant, flexible and extensible forms with easy-to-use validation. Minimal re-renders, no dependencies, and built-in TypeScript support.

★ Editor's Pick TypeScript MIT
7.2M weekly downloads
41.5k GitHub stars
~9.4kB bundle size
v7.54.2 latest version
formsvalidationperformancehookszod

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/resolvers connects Zod, Yup, Joi, etc.
  • Field arrays - useFieldArray for dynamic lists of fields
  • DevTools - @hookform/devtools for 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

  • defaultValues must be set for controlled behaviour - omitting them causes fields to be undefined on first render, which React treats as uncontrolled to controlled switch warnings
  • watch triggers re-renders - avoid watching every field in a large form; use getValues() inside event handlers instead
  • File inputs - register doesn't work for <input type="file">; use Controller with manual file handling
  • Array field performance - use useFieldArray instead of watch + manual array manipulation
  • 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 useActionState and 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

Frequently Asked Questions

Why does React Hook Form cause fewer re-renders than Formik?

React Hook Form uses uncontrolled inputs backed by refs instead of controlled state. Field values are read from the DOM on submit rather than stored in React state on every keystroke. This means typing in a field triggers zero re-renders in most configurations.

How do I integrate Zod validation with React Hook Form?

Install @hookform/resolvers and zod, define a Zod schema, then pass zodResolver(schema) to the resolver option in useForm. The schema infers TypeScript types automatically - no need to write them manually.

Can I use React Hook Form with UI libraries like MUI or React Select?

Yes. Use the Controller component to wrap any third-party input. Controller provides field props (value, onChange, onBlur, ref) that connect to React Hook Form's internal state.

What is the difference between register and Controller?

register works with native HTML inputs that accept a ref. Controller is for controlled components that do not expose a ref - such as React Select, MUI TextField, or Radix UI primitives.

How do I reset a form programmatically?

Call reset() from useForm. Pass an optional values object to reset to specific values: reset({ email: '', password: '' }). You can also call reset() after a successful submit inside the handleSubmit callback.