React Hook Form logo

react hook form

Form validation and submission for React

$ npx docs2skills add react-hook-form
SKILL.md

React Hook Form

Performant forms with easy validation and minimal re-renders

What this skill does

React Hook Form is a library that simplifies form handling in React by embracing uncontrolled components and HTML standards. It reduces the number of re-renders compared to controlled components, improving performance especially for large forms.

The library provides built-in form validation aligned with HTML5 standards, seamless integration with UI libraries through Controller components, and excellent TypeScript support. It eliminates the need for complex state management while providing powerful validation, error handling, and submission capabilities with minimal boilerplate code.

Prerequisites

  • React 16.8+ (hooks support)
  • For schema validation: @hookform/resolvers + schema library (Yup, Zod, etc.)
  • For UI libraries: May need specific adapters or Controller wrapper

Quick start

npm install react-hook-form
import { useForm } from "react-hook-form"

type FormData = {
  firstName: string
  email: string
}

export default function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>()
  
  const onSubmit = (data: FormData) => console.log(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName", { required: true })} />
      {errors.firstName && <span>First name is required</span>}
      
      <input {...register("email", { 
        required: "Email is required",
        pattern: {
          value: /^\S+@\S+$/i,
          message: "Invalid email"
        }
      })} />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input type="submit" />
    </form>
  )
}

Core concepts

Uncontrolled Components: Form inputs maintain their own state, registered with the form through refs. This reduces re-renders compared to controlled components.

Registration: Each input must be registered using register() with a unique name. This connects the input to the form's validation and submission system.

Validation: Built-in HTML5 validation rules (required, min, max, pattern) plus custom validation functions. Validation runs on submit by default.

Form State: Centralized state management through formState object containing errors, touched fields, dirty state, and submission status.

Key API surface

// Primary hook
const {
  register,           // Register input: register("name", options)
  handleSubmit,       // Form submission wrapper
  formState,         // { errors, isSubmitting, isDirty, isValid }
  control,           // For Controller components
  watch,             // Watch field values: watch("fieldName")
  setValue,          // Programmatically set values
  getValues,         // Get current form values
  reset,             // Reset form to defaults
  trigger,           // Manually trigger validation
  clearErrors,       // Clear specific errors
  setError           // Set custom errors
} = useForm<T>(options)

// For UI library integration
<Controller
  name="fieldName"
  control={control}
  rules={{ required: true }}
  render={({ field, fieldState }) => (
    <CustomInput {...field} error={fieldState.error} />
  )}
/>

// Form component for API integration
<Form
  action="/api/endpoint"
  control={control}
  onSuccess={handleSuccess}
  onError={handleError}
>

Common patterns

Basic registration with validation:

<input {...register("username", {
  required: "Username is required",
  minLength: { value: 3, message: "Min 3 characters" },
  validate: value => value !== "admin" || "Username unavailable"
})} />

UI library integration:

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

<Controller
  name="description"
  control={control}
  rules={{ required: true }}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      error={!!fieldState.error}
      helperText={fieldState.error?.message}
    />
  )}
/>

Schema validation with Zod:

import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"

const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18)
})

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

Custom validation and error handling:

const {
  register,
  handleSubmit,
  setError,
  formState: { errors }
} = useForm()

const onSubmit = async (data) => {
  try {
    await submitForm(data)
  } catch (error) {
    if (error.field) {
      setError(error.field, { message: error.message })
    }
  }
}

Watching form values:

const watchedValues = watch(["firstName", "lastName"])
const firstName = watch("firstName")

// Watch all fields
const allValues = watch()

Configuration

useForm({
  mode: "onChange",           // When to validate: onChange, onBlur, onSubmit
  reValidateMode: "onChange", // When to re-validate after errors
  defaultValues: {},          // Initial form values
  resolver: zodResolver(schema), // External validation schema
  context: {},               // Context for validation
  criteriaMode: "firstError", // Return first error or all errors
  shouldFocusError: true,    // Auto-focus first error field
  delayError: 0,             // Delay error display
  shouldUseNativeValidation: false, // Use browser validation
  shouldUnregister: false     // Keep field values when unmounted
})

Best practices

  • Use TypeScript: Define form data types for better developer experience and validation
  • Uncontrolled first: Use register() for native inputs, Controller only when necessary
  • Validation on submit: Default behavior is optimal; change validation mode only if needed
  • Schema validation: Use Yup/Zod for complex validation instead of inline rules
  • Error boundaries: Handle form submission errors gracefully with proper error states
  • Default values: Always provide defaultValues for better form UX and avoiding undefined
  • Form state optimization: Use formState destructuring to prevent unnecessary re-renders
  • Watch selectively: Only watch specific fields you need to avoid performance issues
  • Reset after submission: Call reset() after successful form submission for better UX
  • Accessibility: Use proper ARIA attributes and error associations for screen readers

Gotchas and common mistakes

Field names must be unique strings - Each registered field needs a unique name property for the registration process.

Default values vs controlled components - Don't mix value prop with register(). Use defaultValue for uncontrolled or Controller for controlled.

Watch causes re-renders - watch() triggers component re-renders. Use getValues() in event handlers for one-time reads.

Validation mode affects UX - mode: "onChange" validates on every keystroke which can be jarring. Consider onBlur or onSubmit.

Async validation timing - Custom validate functions with async operations need proper error handling and loading states.

Form reset behavior - reset() without arguments uses initial defaultValues, not current form state.

Nested object field names - Use dot notation for nested fields: register("user.address.street").

Array field complexity - Dynamic arrays need useFieldArray hook, not manual array manipulation.

Controller render function - Always spread field props and handle fieldState for proper integration.

Uncontrolled to controlled warning - Switching between undefined and defined values triggers React warnings. Always use defaultValues.

Schema validation overrides - When using resolver, inline validation rules in register() are ignored.

Form submission prevents default - handleSubmit automatically prevents default form submission; don't call e.preventDefault() again.

React Native differences - Use Controller for all React Native inputs as they don't support refs the same way.