ReactReact Hook Form Overview

React Hook Form Overview

Managing forms manually — tracking field values, touched state, error messages, and validation timing — generates a lot of boilerplate. React Hook Form (RHF) is a lightweight library that eliminates most of that code while delivering better performance than fully controlled forms.

Why React Hook Form?
  • Fewer re-renders — inputs are uncontrolled by default; the form only re-renders on submit and explicit watch() calls

  • Less code — no manual useState for field values, no onChange handlers, no touched tracking

  • Built-in validation — required, minLength, maxLength, pattern, validate (custom function), and seamless schema integration (Zod, Yup)

  • TypeScript-first — generic useForm<T>() types your form values automatically

  • Small bundle — ~9 KB gzipped, zero dependencies

Installation

Bash
npm install react-hook-form
The Core API

Everything starts with the useForm() hook, which returns a set of helpers:

JSX
import { useForm } from 'react-hook-form'

const {
  register,           // connects an input to RHF
  handleSubmit,       // wraps your submit function, handles preventDefault
  formState: { errors, isSubmitting, isValid },
  watch,              // subscribe to a field's live value
  reset,              // reset form to default values
  setValue,           // programmatically set a field value
  getValues,          // read current values without subscribing
} = useForm({
  defaultValues: {
    firstName : '',
    email     : '',
    password  : '',
  },
  mode: 'onBlur',   // when to trigger validation: onChange | onBlur | onSubmit (default)
})
register()

register(name, options) returns an object with ref, name, onChange, and onBlur — spread it onto any input to connect it to RHF:

JSX
<input
  type="email"
  placeholder="Email"
  {...register('email', {
    required  : 'Email is required.',
    pattern   : {
      value   : /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message : 'Enter a valid email address.',
    },
  })}
/>
handleSubmit()

Wrap your submit handler in handleSubmit. RHF will run all validation first and only call your function if the form is valid:

JSX
function onSubmit(data) {
  // 'data' is a fully typed object with all field values
  console.log(data)
  // { firstName: 'Alice', email: 'alice@example.com', password: 'Secret1!' }
}

<form onSubmit={handleSubmit(onSubmit)}>
formState.errors

When validation fails, errors is populated with objects containing a message string from your validation rules:

JSX
{errors.email && (
  <span role="alert" style={{ color: 'red' }}>
    {errors.email.message}
  </span>
)}
A Complete Form with React Hook Form

JSX
import { useForm } from 'react-hook-form'

function SignupForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm({
    defaultValues: {
      firstName       : '',
      lastName        : '',
      email           : '',
      password        : '',
      confirmPassword : '',
      role            : 'viewer',
      newsletter      : false,
    },
    mode: 'onBlur',
  })

  // Watch password field to cross-validate confirmPassword
  const password = watch('password')

  async function onSubmit(data) {
    // Simulate async API call
    await new Promise(resolve => setTimeout(resolve, 1000))
    console.log('Registered:', data)
    alert(`Welcome, ${data.firstName}!`)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate style={{ display: 'grid', gap: 16 }}>

      {/* First Name */}
      <div>
        <label>First name *</label>
        <input
          {...register('firstName', {
            required  : 'First name is required.',
            minLength : { value: 2, message: 'At least 2 characters.' },
          })}
        />
        {errors.firstName && <span role="alert">{errors.firstName.message}</span>}
      </div>

      {/* Last Name */}
      <div>
        <label>Last name</label>
        <input {...register('lastName')} />
      </div>

      {/* Email */}
      <div>
        <label>Email *</label>
        <input
          type="email"
          {...register('email', {
            required : 'Email is required.',
            pattern  : {
              value   : /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message : 'Enter a valid email address.',
            },
          })}
        />
        {errors.email && <span role="alert">{errors.email.message}</span>}
      </div>

      {/* Password */}
      <div>
        <label>Password *</label>
        <input
          type="password"
          {...register('password', {
            required  : 'Password is required.',
            minLength : { value: 8, message: 'Minimum 8 characters.' },
            pattern   : {
              value   : /^(?=.*[A-Z])(?=.*[0-9])/,
              message : 'Must contain at least one uppercase letter and one number.',
            },
          })}
        />
        {errors.password && <span role="alert">{errors.password.message}</span>}
      </div>

      {/* Confirm Password */}
      <div>
        <label>Confirm password *</label>
        <input
          type="password"
          {...register('confirmPassword', {
            required : 'Please confirm your password.',
            validate : value => value === password || 'Passwords do not match.',
          })}
        />
        {errors.confirmPassword && (
          <span role="alert">{errors.confirmPassword.message}</span>
        )}
      </div>

      {/* Role */}
      <div>
        <label>Role</label>
        <select {...register('role')}>
          <option value="viewer">Viewer</option>
          <option value="editor">Editor</option>
          <option value="admin">Admin</option>
        </select>
      </div>

      {/* Newsletter */}
      <label>
        <input type="checkbox" {...register('newsletter')} />
        {' Subscribe to newsletter'}
      </label>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating account...' : 'Create Account'}
      </button>
    </form>
  )
}
Tip
The `isSubmitting` flag from `formState` is true while your async submit handler is running. Use it to disable the button and prevent double-submits without any extra state.
Validation Rules

Rule

Type

Example

required

boolean | string

required: 'Name is required.'

minLength

{ value, message }

minLength: { value: 3, message: 'Too short.' }

maxLength

{ value, message }

maxLength: { value: 50, message: 'Too long.' }

min

{ value, message }

min: { value: 18, message: 'Must be 18+.' }

max

{ value, message }

max: { value: 99, message: 'Must be under 99.' }

pattern

{ value: RegExp, message }

pattern: { value: /^[A-Z]+$/, message: 'Uppercase only.' }

validate

(value) => true | string

validate: v => v !== 'admin' || 'Reserved name.'

Integration with MUI (Material-UI)

RHF works with any component library. For MUI, use the Controller wrapper when a component does not expose a native HTML ref:

JSX
import { useForm, Controller } from 'react-hook-form'
import { TextField, Checkbox, FormControlLabel } from '@mui/material'

function MuiForm() {
  const { control, handleSubmit, formState: { errors } } = useForm()

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <Controller
        name="email"
        control={control}
        defaultValue=""
        rules={{ required: 'Email is required.' }}
        render={({ field }) => (
          <TextField
            {...field}
            label="Email"
            type="email"
            error={!!errors.email}
            helperText={errors.email?.message}
          />
        )}
      />
      <button type="submit">Submit</button>
    </form>
  )
}
Note
Use `Controller` for any controlled UI component (MUI, Ant Design, React Select, etc.) that does not forward a native ref. For plain HTML inputs, use `register()` — it is faster and generates less code.
RHF vs Manual Controlled Forms

Aspect

Manual Controlled

React Hook Form

Re-renders per keystroke

One per field

Zero (uncontrolled)

Boilerplate

High (state + handlers + errors + touched)

Minimal

Validation library

Custom or Zod (manual wire-up)

Built-in + Zod/Yup resolver

Bundle size

Zero (built-in React)

~9 KB gzipped

TypeScript support

Manual typing

Generic useForm<T>

Integration with UI libs

Native

Controller wrapper needed

Warning
React Hook Form's uncontrolled approach means you cannot read a field's value in real time without calling `watch('fieldName')`. Calling `watch` re-subscribes the component to that field's changes and resumes re-renders for it — use it deliberately.