React Hook Form

Learn how to build performant, flexible, and extensible forms with React Hook Form, using easy-to-use validation and minimal re-renders.

Introduction to React Hook Form

React Hook Form

React Hook Form is a performant, flexible, and extensible form library that leverages React hooks and uncontrolled components to provide excellent performance with minimal code. It's designed to reduce the amount of code you need to write while maintaining full control over your forms.

Why React Hook Form?

Key Benefits

  1. Performance: Minimizes re-renders by using uncontrolled components
  2. Small Bundle Size: Only ~8.6kB gzipped with zero dependencies
  3. Easy to Adopt: Simple API that's easy to learn and use
  4. Flexible Validation: Supports HTML standard validation and schema validation
  5. Great DX: Excellent TypeScript support and DevTools
  6. UI Library Friendly: Easy integration with any UI component library

Performance Comparison

React Hook Form isolates component re-renders, meaning only the components that need to update will re-render, unlike traditional controlled component approaches where the entire form re-renders on every keystroke.

// Traditional Controlled Form (Re-renders on every keystroke)
function ControlledForm() {
  const [data, setData] = useState({ name: '', email: '' });
  // Entire form re-renders when any field changes
}

// React Hook Form (Minimal re-renders)
function UncontrolledForm() {
  const { register } = useForm();
  // Only necessary parts re-render
}

Installation

Install React Hook Form using yarn:

yarn add react-hook-form

For TypeScript projects, types are included by default.

Basic Usage

Simple Form Example

import { useForm, SubmitHandler } from 'react-hook-form';

interface FormData {
  email: string;
  password: string;
}

export default function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<FormData>();

  const onSubmit: SubmitHandler<FormData> = async (data) => {
    console.log(data);
    // Handle form submission
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'Invalid email address'
            }
          })}
        />
        {errors.email && (
          <span className="error">{errors.email.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
        />
        {errors.password && (
          <span className="error">{errors.password.message}</span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Login'}
      </button>
    </form>
  );
}

Core Concepts

1. Registration

The register function connects input fields to React Hook Form:

const { register } = useForm();

// Register an input
<input {...register('fieldName')} />

// Register with validation rules
<input
  {...register('fieldName', {
    required: 'This field is required',
    minLength: { value: 3, message: 'Min length is 3' },
    maxLength: { value: 20, message: 'Max length is 20' },
    pattern: { value: /^[A-Za-z]+$/i, message: 'Letters only' }
  })}
/>

2. Validation Rules

React Hook Form supports various built-in validation rules:

<input
  {...register('username', {
    required: 'Username is required',
    minLength: { value: 3, message: 'Too short' },
    maxLength: { value: 20, message: 'Too long' },
    pattern: {
      value: /^[a-zA-Z0-9_]+$/,
      message: 'Only letters, numbers, and underscores'
    },
    validate: {
      noSpaces: (value) => 
        !/\s/.test(value) || 'No spaces allowed',
      startsWithLetter: (value) =>
        /^[a-zA-Z]/.test(value) || 'Must start with a letter'
    }
  })}
/>

3. Form State

Access various form states through formState:

const {
  formState: {
    errors,        // Validation errors
    isDirty,       // Form has been modified
    isValid,       // Form is valid
    isSubmitting,  // Form is being submitted
    isSubmitted,   // Form has been submitted
    touchedFields, // Fields that have been touched
    dirtyFields    // Fields that have been modified
  }
} = useForm();

4. Handling Submission

const onSubmit: SubmitHandler<FormData> = async (data) => {
  try {
    await api.submitForm(data);
    // Handle success
  } catch (error) {
    // Handle error
  }
};

<form onSubmit={handleSubmit(onSubmit)}>
  {/* form fields */}
</form>

Advanced Features

Watch Field Values

Monitor field values in real-time:

const { watch } = useForm();

// Watch all fields
const watchAllFields = watch();

// Watch specific field
const watchEmail = watch('email');

// Watch multiple fields
const watchFields = watch(['email', 'password']);

// Use in component
const email = watch('email');
return <p>Current email: {email}</p>;

Set Values Programmatically

const { setValue, reset } = useForm();

// Set a single value
setValue('email', 'user@example.com');

// Set multiple values
setValue('email', 'user@example.com', {
  shouldValidate: true,  // Trigger validation
  shouldDirty: true,     // Mark field as dirty
  shouldTouch: true      // Mark field as touched
});

// Reset form to default values
reset();

// Reset to specific values
reset({ email: '', password: '' });

Custom Validation

Implement complex validation logic:

const { register } = useForm();

<input
  {...register('password', {
    validate: {
      hasUpperCase: (value) =>
        /[A-Z]/.test(value) || 'Must contain uppercase letter',
      hasLowerCase: (value) =>
        /[a-z]/.test(value) || 'Must contain lowercase letter',
      hasNumber: (value) =>
        /[0-9]/.test(value) || 'Must contain a number',
      hasSpecialChar: (value) =>
        /[!@#$%^&*]/.test(value) || 'Must contain special character'
    }
  })}
/>

Async Validation

Validate against external services:

<input
  {...register('username', {
    validate: async (value) => {
      const response = await fetch(`/api/check-username/${value}`);
      const isAvailable = await response.json();
      return isAvailable || 'Username already taken';
    }
  })}
/>

Dependent Fields

Validate fields based on other field values:

const { register, watch } = useForm();
const password = watch('password');

<input
  {...register('confirmPassword', {
    validate: (value) =>
      value === password || 'Passwords do not match'
  })}
/>

Integration with UI Libraries

With Custom Components

Use the Controller component for controlled components:

import { Controller, useForm } from 'react-hook-form';
import Select from 'react-select';

function MyForm() {
  const { control, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="country"
        control={control}
        rules={{ required: 'Country is required' }}
        render={({ field, fieldState: { error } }) => (
          <div>
            <Select
              {...field}
              options={countries}
              placeholder="Select country"
            />
            {error && <span>{error.message}</span>}
          </div>
        )}
      />
    </form>
  );
}

With Material-UI

import { Controller } from 'react-hook-form';
import { TextField } from '@mui/material';

<Controller
  name="email"
  control={control}
  rules={{ required: 'Email is required' }}
  render={({ field, fieldState: { error } }) => (
    <TextField
      {...field}
      label="Email"
      error={!!error}
      helperText={error?.message}
    />
  )}
/>

With shadcn/ui

import { Controller } from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

<Controller
  name="username"
  control={control}
  render={({ field, fieldState: { error } }) => (
    <div>
      <Label htmlFor="username">Username</Label>
      <Input
        {...field}
        id="username"
        placeholder="Enter username"
      />
      {error && <p className="text-sm text-red-500">{error.message}</p>}
    </div>
  )}
/>

Schema Validation

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, 'Password must be at least 8 characters'),
  age: z.number().min(18, 'Must be 18 or older')
});

type FormData = z.infer<typeof schema>;

function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<FormData>({
    resolver: zodResolver(schema)
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}
      
      <input {...register('age', { valueAsNumber: true })} type="number" />
      {errors.age && <span>{errors.age.message}</span>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

With Yup

import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const schema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().min(8).required(),
  age: yup.number().positive().integer().min(18).required()
});

const { register, handleSubmit } = useForm({
  resolver: yupResolver(schema)
});

Mode Options

Control when validation occurs:

useForm({
  mode: 'onSubmit',    // Validate on submit (default)
  mode: 'onBlur',      // Validate when field loses focus
  mode: 'onChange',    // Validate on every change
  mode: 'onTouched',   // Validate after field is touched
  mode: 'all',         // Validate on both blur and change
  
  reValidateMode: 'onChange'  // Re-validate on change after first submit
});

Field Arrays

Handle dynamic form fields:

import { useForm, useFieldArray } from 'react-hook-form';

function DynamicForm() {
  const { register, control } = useForm({
    defaultValues: {
      items: [{ name: '', quantity: 0 }]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items'
  });

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`items.${index}.name` as const)}
            placeholder="Item name"
          />
          <input
            {...register(`items.${index}.quantity` as const, {
              valueAsNumber: true
            })}
            type="number"
            placeholder="Quantity"
          />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      
      <button
        type="button"
        onClick={() => append({ name: '', quantity: 0 })}
      >
        Add Item
      </button>
    </form>
  );
}

Error Handling

Display Errors

// Single error
{errors.email && <span>{errors.email.message}</span>}

// Multiple errors for one field
{errors.password?.types?.required && <span>Required</span>}
{errors.password?.types?.minLength && <span>Too short</span>}

// All errors
{Object.keys(errors).length > 0 && (
  <div className="error-summary">
    <h4>Please fix the following errors:</h4>
    <ul>
      {Object.entries(errors).map(([field, error]) => (
        <li key={field}>{error.message}</li>
      ))}
    </ul>
  </div>
)}

Set Errors Manually

const { setError } = useForm();

// Set error for specific field
setError('email', {
  type: 'manual',
  message: 'Email already exists'
});

// Set multiple errors
setError('root.serverError', {
  type: 'server',
  message: 'Server error occurred'
});

DevTools

Install and use React Hook Form DevTools for debugging:

yarn add -D @hookform/devtools
import { useForm } from 'react-hook-form';
import { DevTool } from '@hookform/devtools';

function MyForm() {
  const { register, control } = useForm();

  return (
    <>
      <form>{/* form fields */}</form>
      <DevTool control={control} />
    </>
  );
}

Best Practices

1. Use TypeScript

interface FormData {
  email: string;
  password: string;
  rememberMe: boolean;
}

const { register } = useForm<FormData>();

2. Provide Clear Error Messages

<input
  {...register('email', {
    required: 'Email is required',
    pattern: {
      value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
      message: 'Please enter a valid email address'
    }
  })}
/>

3. Use Schema Validation for Complex Forms

For forms with many fields or complex validation logic, use Zod or Yup.

4. Handle Loading States

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? 'Submitting...' : 'Submit'}
</button>

5. Reset Form After Submission

const onSubmit = async (data) => {
  await submitForm(data);
  reset(); // Reset form to default values
};

Complete Example

Here's a comprehensive registration form example:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const registrationSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
  email: z.string().email('Invalid email address'),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase letter')
    .regex(/[a-z]/, 'Must contain lowercase letter')
    .regex(/[0-9]/, 'Must contain number'),
  confirmPassword: z.string(),
  age: z.number().min(18, 'Must be 18 or older'),
  terms: z.boolean().refine((val) => val === true, {
    message: 'You must accept the terms and conditions'
  })
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
});

type RegistrationData = z.infer<typeof registrationSchema>;

export default function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    reset
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    mode: 'onBlur'
  });

  const onSubmit = async (data: RegistrationData) => {
    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });

      if (response.ok) {
        alert('Registration successful!');
        reset();
      } else {
        throw new Error('Registration failed');
      }
    } catch (error) {
      console.error('Error:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto p-6">
      <h2 className="text-2xl font-bold mb-6">Register</h2>

      <div className="mb-4">
        <label htmlFor="username" className="block mb-2">Username</label>
        <input
          id="username"
          {...register('username')}
          className="w-full p-2 border rounded"
        />
        {errors.username && (
          <p className="text-red-500 text-sm mt-1">{errors.username.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="email" className="block mb-2">Email</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          className="w-full p-2 border rounded"
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="password" className="block mb-2">Password</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          className="w-full p-2 border rounded"
        />
        {errors.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="confirmPassword" className="block mb-2">Confirm Password</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword')}
          className="w-full p-2 border rounded"
        />
        {errors.confirmPassword && (
          <p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="age" className="block mb-2">Age</label>
        <input
          id="age"
          type="number"
          {...register('age', { valueAsNumber: true })}
          className="w-full p-2 border rounded"
        />
        {errors.age && (
          <p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
        )}
      </div>

      <div className="mb-6">
        <label className="flex items-center">
          <input
            type="checkbox"
            {...register('terms')}
            className="mr-2"
          />
          <span>I accept the terms and conditions</span>
        </label>
        {errors.terms && (
          <p className="text-red-500 text-sm mt-1">{errors.terms.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting || !isDirty}
        className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:bg-gray-400"
      >
        {isSubmitting ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}