Skip to main content
Technology & EngineeringForms Validation252 lines

React Hook Form

React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns

Quick Summary24 lines
React Hook Form embraces uncontrolled components and native HTML validation to minimize re-renders. Instead of managing every keystroke in state, it registers inputs via refs, only triggering re-renders when validation state changes. This architecture delivers exceptional performance even in forms with hundreds of fields. The library provides a small API surface — `useForm`, `register`, `handleSubmit`, `Controller` — that covers the vast majority of form scenarios without boilerplate.

## Key Points

- **Always provide `defaultValues`** — this enables `reset()` to work correctly, improves type inference, and avoids uncontrolled-to-controlled warnings.
- **Use `mode: "onBlur"`** for most forms — it gives users time to type before showing errors, reducing noise.
- **Prefer `zodResolver`** over inline `rules` for any form with cross-field validation or complex logic. It keeps validation co-located and testable.
- **Use `useFieldArray` for dynamic lists** — never manually manage array indices in state alongside RHF.
- **Leverage `valueAsNumber` and `valueAsDate`** on register options to avoid manual type coercion.
- **Isolate heavy components with `useWatch`** — subscribe to specific fields in child components instead of watching at the form root, preventing unnecessary re-renders.
- **Call `reset()` after successful submission** when the form stays mounted (e.g., modals that reopen).
- **Set `shouldUnregister: false`** (the default) for wizard/multi-step forms so that navigating between steps preserves values.
- **Using array index as `key`** — always use the `field.id` provided by `useFieldArray` as the React key. Index-based keys cause inputs to lose their values when items are reordered or removed.
- **Calling `setValue` in a render loop** — this triggers re-renders. Move it into `useEffect` or event handlers.
- **Skipping `noValidate` on the form element** — without it, native browser validation tooltips appear alongside your custom error messages, confusing users.
- **Overusing `trigger()`** — manually triggering validation on every change defeats the performance benefit of the library. Trust the configured `mode` and `reValidateMode`.

## Quick Example

```typescript
npm install react-hook-form @hookform/resolvers zod
```
skilldb get forms-validation-skills/React Hook FormFull skill: 252 lines
Paste into your CLAUDE.md or agent config

React Hook Form

Core Philosophy

React Hook Form embraces uncontrolled components and native HTML validation to minimize re-renders. Instead of managing every keystroke in state, it registers inputs via refs, only triggering re-renders when validation state changes. This architecture delivers exceptional performance even in forms with hundreds of fields. The library provides a small API surface — useForm, register, handleSubmit, Controller — that covers the vast majority of form scenarios without boilerplate.

Setup

Install the core library and optional schema resolver:

npm install react-hook-form @hookform/resolvers zod

Basic form scaffold:

import { useForm } from "react-hook-form";

interface SignupForm {
  email: string;
  password: string;
  confirmPassword: string;
}

export function SignupPage() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupForm>({
    defaultValues: { email: "", password: "", confirmPassword: "" },
    mode: "onBlur",        // validate on blur
    reValidateMode: "onChange", // re-validate on change after first error
  });

  const onSubmit = async (data: SignupForm) => {
    await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input
        type="email"
        {...register("email", {
          required: "Email is required",
          pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Invalid email" },
        })}
      />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <input
        type="password"
        {...register("password", {
          required: "Password is required",
          minLength: { value: 8, message: "At least 8 characters" },
        })}
      />
      {errors.password && <span role="alert">{errors.password.message}</span>}

      <button type="submit" disabled={isSubmitting}>Sign Up</button>
    </form>
  );
}

Key Techniques

Schema-Based 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, "At least 8 characters"),
    confirmPassword: z.string(),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords must match",
    path: ["confirmPassword"],
  });

type FormData = z.infer<typeof schema>;

export function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: { email: "", password: "", confirmPassword: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      {/* remaining fields */}
    </form>
  );
}

Controller for Controlled Components

Use Controller when integrating with UI libraries (MUI, Radix, React Select) that require value/onChange props:

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

interface ProjectForm {
  name: string;
  priority: { value: string; label: string };
}

export function ProjectSettings() {
  const { control, register, handleSubmit } = useForm<ProjectForm>();

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register("name", { required: true })} />

      <Controller
        name="priority"
        control={control}
        rules={{ required: "Select a priority" }}
        render={({ field, fieldState: { error } }) => (
          <>
            <Select
              {...field}
              options={[
                { value: "low", label: "Low" },
                { value: "medium", label: "Medium" },
                { value: "high", label: "High" },
              ]}
            />
            {error && <span>{error.message}</span>}
          </>
        )}
      />
    </form>
  );
}

Dynamic Arrays with useFieldArray

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

interface InvoiceForm {
  lineItems: { description: string; amount: number }[];
}

export function InvoiceEditor() {
  const { control, register, handleSubmit } = useForm<InvoiceForm>({
    defaultValues: { lineItems: [{ description: "", amount: 0 }] },
  });

  const { fields, append, remove, move } = useFieldArray({
    control,
    name: "lineItems",
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`lineItems.${index}.description` as const, { required: true })} />
          <input
            type="number"
            {...register(`lineItems.${index}.amount` as const, { valueAsNumber: true })}
          />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ description: "", amount: 0 })}>
        Add Item
      </button>
      <button type="submit">Submit</button>
    </form>
  );
}

Nested Fields and Watch

const { register, watch, setValue } = useForm<{
  address: { street: string; city: string; state: string; zip: string };
}>();

// Watch a specific nested field
const zip = watch("address.zip");

// Auto-fill city/state from ZIP lookup
useEffect(() => {
  if (zip?.length === 5) {
    lookupZip(zip).then(({ city, state }) => {
      setValue("address.city", city);
      setValue("address.state", state);
    });
  }
}, [zip, setValue]);

DevTools

import { DevTool } from "@hookform/devtools";

function MyForm() {
  const { control } = useForm();
  return (
    <>
      <form>{/* fields */}</form>
      {process.env.NODE_ENV === "development" && <DevTool control={control} />}
    </>
  );
}

Best Practices

  • Always provide defaultValues — this enables reset() to work correctly, improves type inference, and avoids uncontrolled-to-controlled warnings.
  • Use mode: "onBlur" for most forms — it gives users time to type before showing errors, reducing noise.
  • Prefer zodResolver over inline rules for any form with cross-field validation or complex logic. It keeps validation co-located and testable.
  • Use useFieldArray for dynamic lists — never manually manage array indices in state alongside RHF.
  • Leverage valueAsNumber and valueAsDate on register options to avoid manual type coercion.
  • Isolate heavy components with useWatch — subscribe to specific fields in child components instead of watching at the form root, preventing unnecessary re-renders.
  • Call reset() after successful submission when the form stays mounted (e.g., modals that reopen).
  • Set shouldUnregister: false (the default) for wizard/multi-step forms so that navigating between steps preserves values.

Anti-Patterns

  • Mixing controlled state with register — do not use useState alongside register for the same input. This causes double sources of truth and subtle bugs. Use watch or useWatch if you need to read a value reactively.
  • Using array index as key — always use the field.id provided by useFieldArray as the React key. Index-based keys cause inputs to lose their values when items are reordered or removed.
  • Calling setValue in a render loop — this triggers re-renders. Move it into useEffect or event handlers.
  • Skipping noValidate on the form element — without it, native browser validation tooltips appear alongside your custom error messages, confusing users.
  • Overusing trigger() — manually triggering validation on every change defeats the performance benefit of the library. Trust the configured mode and reValidateMode.
  • Forgetting as const on template literal field names — without it, TypeScript cannot narrow the type for nested or array paths, losing type safety.

Install this skill directly: skilldb add forms-validation-skills

Get CLI access →