React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

react-hook-formuseFormregisterControllerforms

React Hook Form

Performant, flexible forms with minimal re-renders — setup, register, Controller, and core patterns.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useForm, Controller } from "react-hook-form";
 
interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}
 
function LoginForm() {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting },
  } = useForm<LoginForm>({
    defaultValues: { email: "", password: "", rememberMe: false },
  });
 
  async function onSubmit(data: LoginForm) {
    await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* register — for native inputs */}
      <input {...register("email", { required: "Email is required" })} />
      {errors.email && <span>{errors.email.message}</span>}
 
      <input type="password" {...register("password", { minLength: 8 })} />
 
      {/* Controller — for controlled components */}
      <Controller
        name="rememberMe"
        control={control}
        render={({ field }) => (
          <label>
            <input type="checkbox" checked={field.value} onChange={field.onChange} />
            Remember me
          </label>
        )}
      />
 
      <button type="submit" disabled={isSubmitting}>Log in</button>
    </form>
  );
}

When to reach for this: When you need a form with validation, error handling, and good performance — especially when the form has more than a couple of fields.

Working Example

"use client";
 
import { useForm, useFieldArray } from "react-hook-form";
 
interface Ingredient {
  name: string;
  amount: string;
}
 
interface RecipeFormData {
  title: string;
  description: string;
  servings: number;
  ingredients: Ingredient[];
}
 
export function RecipeEditor() {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting, isDirty },
    reset,
    watch,
  } = useForm<RecipeFormData>({
    defaultValues: {
      title: "",
      description: "",
      servings: 4,
      ingredients: [{ name: "", amount: "" }],
    },
  });
 
  const { fields, append, remove } = useFieldArray({
    control,
    name: "ingredients",
  });
 
  const watchTitle = watch("title");
 
  function onSubmit(data: RecipeFormData) {
    console.log("Recipe:", data);
    reset(data); // clears isDirty
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
      <h2 className="text-lg font-bold">
        {watchTitle || "New Recipe"}
      </h2>
 
      <div>
        <input
          {...register("title", { required: "Title is required" })}
          placeholder="Recipe title"
          className="w-full rounded border p-2"
        />
        {errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
      </div>
 
      <textarea
        {...register("description")}
        placeholder="Description"
        className="w-full rounded border p-2"
        rows={3}
      />
 
      <input
        type="number"
        {...register("servings", { valueAsNumber: true, min: 1 })}
        className="w-32 rounded border p-2"
      />
 
      <fieldset className="space-y-2">
        <legend className="font-semibold">Ingredients</legend>
        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-2">
            <input
              {...register(`ingredients.${index}.name`, { required: true })}
              placeholder="Ingredient"
              className="flex-1 rounded border p-2"
            />
            <input
              {...register(`ingredients.${index}.amount`)}
              placeholder="Amount"
              className="w-24 rounded border p-2"
            />
            <button type="button" onClick={() => remove(index)} className="text-red-500">
              Remove
            </button>
          </div>
        ))}
        <button
          type="button"
          onClick={() => append({ name: "", amount: "" })}
          className="text-sm text-blue-600"
        >
          + Add ingredient
        </button>
      </fieldset>
 
      <div className="flex gap-3">
        <button
          type="submit"
          disabled={isSubmitting}
          className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
        >
          Save
        </button>
        {isDirty && <span className="self-center text-sm text-amber-600">Unsaved changes</span>}
      </div>
    </form>
  );
}

What this demonstrates:

  • useForm with typed defaultValues
  • register for native inputs with validation rules
  • useFieldArray for dynamic lists
  • watch for real-time field observation
  • isDirty tracking and reset after save

Deep Dive

How It Works

  • RHF uses uncontrolled inputs by default — register attaches ref, onChange, onBlur, and name to the input
  • Form state lives in a ref, not React state — only the parts you subscribe to cause re-renders
  • handleSubmit runs validation first, then calls your onSubmit only if valid
  • formState properties (errors, isDirty, isValid, etc.) are lazily evaluated via Proxy — only accessed properties trigger re-renders
  • Controller bridges controlled components (custom selects, date pickers) into RHF

Variations

Validation modes:

const { register } = useForm({
  mode: "onBlur",         // validate on blur (default: "onSubmit")
  reValidateMode: "onChange", // re-validate on change after first error
});

Set values programmatically:

const { setValue, getValues, trigger } = useForm();
 
// Set a single field
setValue("email", "new@example.com", { shouldValidate: true });
 
// Get all values
const all = getValues();
 
// Trigger validation manually
await trigger("email");       // single field
await trigger();              // all fields

Form-level default values from async data:

const { reset } = useForm<ProfileForm>({
  defaultValues: async () => {
    const res = await fetch("/api/profile");
    return res.json();
  },
});

TypeScript Notes

// Strongly typed register — catches typos at compile time
register("emial"); // TS error: "emial" is not a key of LoginForm
 
// Typed errors
errors.email?.message; // string | undefined
 
// UseFormReturn type for passing form methods as props
import type { UseFormReturn } from "react-hook-form";
 
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
  return <input {...form.register("email")} />;
}
 
// FieldPath for generic field components
import type { FieldPath, FieldValues } from "react-hook-form";
 
function TextInput<T extends FieldValues>({
  name,
  control,
}: {
  name: FieldPath<T>;
  control: Control<T>;
}) {
  return <Controller name={name} control={control} render={({ field }) => <input {...field} />} />;
}

Gotchas

  • Default values must be complete — RHF uses defaultValues to determine the initial isDirty state. Omitting fields leads to incorrect dirty tracking. Fix: Always provide every field in defaultValues.

  • register returns a ref — Do not overwrite the ref returned by register. Fix: Use Controller if you need a custom ref, or merge refs with a callback ref.

  • Re-renders from formState — Destructuring formState at the top level subscribes to all properties. Fix: Only destructure the properties you need: const { errors } = formState.

  • valueAsNumber returns NaN for empty inputs — If the input is empty, valueAsNumber: true gives NaN. Fix: Combine with setValueAs or use Zod coercion via a resolver.

  • useFieldArray keys — Always use field.id as the key, not the array index. Using index causes state bugs when reordering or removing items.

Alternatives

AlternativeUse WhenDon't Use When
Native FormDataSimple server action forms with minimal client logicYou need field-level validation and dynamic fields
FormikYou are in a legacy codebase already using FormikStarting a new project (RHF has better performance)
React 19 useActionStateServer-first forms without client-side JSYou need instant field validation and complex UX
Tanstack FormYou want framework-agnostic form logicYou need the widest ecosystem of resolvers and UI libraries

FAQs

What is the difference between register and Controller?
  • register works with native HTML inputs by attaching ref, onChange, onBlur, and name
  • Controller wraps controlled components (custom selects, date pickers) that need value and onChange props
  • Use register for native inputs; use Controller for third-party or custom components
How does react-hook-form achieve minimal re-renders?
  • Form state lives in a ref, not React state, so updates do not trigger re-renders by default
  • formState properties are lazily evaluated via Proxy -- only accessed properties trigger re-renders
  • Only destructure what you need: const { errors } = formState instead of the whole object
What does isDirty track and how do you reset it?
  • isDirty is true when any field value differs from its defaultValues
  • Call reset(data) after a successful save to update the baseline and set isDirty to false
  • Omitting fields from defaultValues leads to incorrect dirty tracking
How do you load default values from an async data source?
const { reset } = useForm<ProfileForm>({
  defaultValues: async () => {
    const res = await fetch("/api/profile");
    return res.json();
  },
});
  • Pass an async function to defaultValues and RHF resolves it automatically
What does useFieldArray provide and when should you use it?
  • It manages arrays of objects with append, remove, insert, move, swap, replace, and update
  • Each item gets a stable field.id for use as a React key
  • Use it for dynamic lists like ingredients, line items, or addresses
Gotcha: Why should you never overwrite the ref returned by register?
  • register returns a ref that RHF uses to track the input element
  • Overwriting it disconnects RHF from the DOM element
  • Fix: use Controller if you need a custom ref, or merge refs with a callback ref
Gotcha: Why does valueAsNumber return NaN for empty inputs?
  • When the input is empty, Number("") returns NaN
  • valueAsNumber: true uses this conversion, resulting in NaN in the form data
  • Fix: combine with setValueAs or use Zod's z.coerce.number() via a resolver instead
How do you trigger validation manually for a single field in TypeScript?
const { trigger } = useForm<FormData>();
await trigger("email");  // validates just the email field
await trigger();          // validates all fields
  • trigger accepts typed field names from the form's generic parameter
How do you pass form methods to child components with proper TypeScript types?
import type { UseFormReturn } from "react-hook-form";
 
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
  return <input {...form.register("email")} />;
}
  • Use UseFormReturn<T> as the prop type to get full type safety
What validation modes are available and how do they differ?
  • "onSubmit" (default): validates only when the form is submitted
  • "onBlur": validates when a field loses focus
  • "onChange": validates on every keystroke
  • reValidateMode controls re-validation behavior after the first error
How does watch differ from getValues?
  • watch("title") subscribes to changes and triggers re-renders when the value changes
  • getValues("title") reads the current value without subscribing to changes
  • Use watch for reactive UI updates; use getValues for one-time reads