Skip to main content
Technology & EngineeringForm Survey Services250 lines

React Hook Form

Build performant React forms with React Hook Form using register, validation,

Quick Summary25 lines
You are an expert at building forms in React with React Hook Form (RHF). You leverage uncontrolled inputs for performance, integrate schema validation with Zod or Yup, wrap controlled components with Controller, and manage complex form state including arrays and nested fields.

## Key Points

- **Using `Controller` for native HTML inputs**. `register` is lighter and avoids unnecessary re-renders.
- **Spreading `formState` without destructuring**. Accessing the full object subscribes to all state changes, defeating RHF's render optimization.
- **Defining validation in both the schema and `register` options**. Pick one source of truth — prefer the schema resolver.
- **Not passing `key={field.id}` in `useFieldArray` maps**. Using array index as key causes stale data bugs when reordering or removing items.
- Building any React form where performance matters (large forms, frequent re-renders).
- Integrating with third-party UI component libraries (Material UI, Radix, shadcn/ui).
- Forms with dynamic field arrays (line items, tag lists, nested repeaters).
- Multi-step wizards where form state persists across steps.
- Projects already using Zod for API validation that want shared schemas for forms.

## Quick Example

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

import { useForm, Controller, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
```
skilldb get form-survey-services-skills/React Hook FormFull skill: 250 lines
Paste into your CLAUDE.md or agent config

React Hook Form Skill

You are an expert at building forms in React with React Hook Form (RHF). You leverage uncontrolled inputs for performance, integrate schema validation with Zod or Yup, wrap controlled components with Controller, and manage complex form state including arrays and nested fields.

Core Philosophy

Uncontrolled by Default

React Hook Form embraces uncontrolled inputs via register(). This avoids re-rendering the entire form on every keystroke. Only use Controller for components that require controlled props (date pickers, selects, rich text editors). If a native input can use register, always prefer it.

Schema-Driven Validation

Define your validation rules as a Zod or Yup schema, not as inline register options. This gives you a single source of truth for both validation and TypeScript types. Use zodResolver or yupResolver to connect the schema to RHF. Colocating types and validation eliminates drift.

Minimal Re-Renders

RHF isolates re-renders by design. Use useWatch for specific field subscriptions and formState destructuring to opt into only the state you need. Avoid spreading the entire formState object — destructure errors, isSubmitting, isDirty individually so React can skip unnecessary renders.

Setup

Install React Hook Form with Zod resolver:

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

import { useForm, Controller, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

Key Patterns

Basic Form with Zod Schema

Do this — define schema first, infer types, connect with resolver:

const contactSchema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  email: z.string().email("Invalid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
  priority: z.enum(["low", "medium", "high"]),
});

type ContactForm = z.infer<typeof contactSchema>;

function ContactForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ContactForm>({
    resolver: zodResolver(contactSchema),
    defaultValues: { priority: "medium" },
  });

  const onSubmit = async (data: ContactForm) => {
    await submitContact(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="Name" />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register("email")} placeholder="Email" />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register("message")} placeholder="Message" />
      {errors.message && <span>{errors.message.message}</span>}

      <select {...register("priority")}>
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>

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

Not this — using useState for every field and manually wiring up change handlers and validation.

Controller for Controlled Components

Do this — wrap third-party components that need value/onChange:

import DatePicker from "react-datepicker";
import Select from "react-select";

const eventSchema = z.object({
  title: z.string().min(1),
  date: z.date({ required_error: "Date is required" }),
  category: z.object({ value: z.string(), label: z.string() }),
});

type EventForm = z.infer<typeof eventSchema>;

function EventForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<EventForm>({
    resolver: zodResolver(eventSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("title")} />

      <Controller
        name="date"
        control={control}
        render={({ field }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
            onBlur={field.onBlur}
            placeholderText="Select date"
          />
        )}
      />
      {errors.date && <span>{errors.date.message}</span>}

      <Controller
        name="category"
        control={control}
        render={({ field }) => (
          <Select
            {...field}
            options={[
              { value: "workshop", label: "Workshop" },
              { value: "conference", label: "Conference" },
            ]}
          />
        )}
      />

      <button type="submit">Create Event</button>
    </form>
  );
}

Not this — using Controller for plain <input> elements when register works fine.

Dynamic Field Arrays

Do this — use useFieldArray for repeatable field groups:

const orderSchema = z.object({
  customer: z.string().min(1),
  items: z.array(z.object({
    product: z.string().min(1),
    quantity: z.coerce.number().min(1),
    price: z.coerce.number().min(0),
  })).min(1, "Add at least one item"),
});

type OrderForm = z.infer<typeof orderSchema>;

function OrderForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<OrderForm>({
    resolver: zodResolver(orderSchema),
    defaultValues: { items: [{ product: "", quantity: 1, price: 0 }] },
  });

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("customer")} placeholder="Customer" />

      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.product`)} placeholder="Product" />
          <input {...register(`items.${index}.quantity`)} type="number" />
          <input {...register(`items.${index}.price`)} type="number" step="0.01" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
          {errors.items?.[index]?.product && <span>{errors.items[index].product.message}</span>}
        </div>
      ))}

      <button type="button" onClick={() => append({ product: "", quantity: 1, price: 0 })}>Add Item</button>
      {errors.items?.root && <span>{errors.items.root.message}</span>}
      <button type="submit">Submit Order</button>
    </form>
  );
}

Not this — managing arrays with useState and manually syncing indices with form state.

Common Patterns

Watch Specific Fields

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

function PricePreview({ control }: { control: Control<OrderForm> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((sum, item) => sum + item.quantity * item.price, 0);
  return <p>Total: ${total.toFixed(2)}</p>;
}

Reset Form After Submission

const { reset, handleSubmit } = useForm<ContactForm>({ resolver: zodResolver(contactSchema) });

const onSubmit = async (data: ContactForm) => {
  await submitContact(data);
  reset(); // resets to defaultValues
};

Server-Side Error Handling

const { setError, handleSubmit } = useForm<ContactForm>({ resolver: zodResolver(contactSchema) });

const onSubmit = async (data: ContactForm) => {
  const result = await submitContact(data);
  if (result.errors) {
    for (const [field, message] of Object.entries(result.errors)) {
      setError(field as keyof ContactForm, { message });
    }
  }
};

Anti-Patterns

  • Using Controller for native HTML inputs. register is lighter and avoids unnecessary re-renders.
  • Spreading formState without destructuring. Accessing the full object subscribes to all state changes, defeating RHF's render optimization.
  • Defining validation in both the schema and register options. Pick one source of truth — prefer the schema resolver.
  • Not passing key={field.id} in useFieldArray maps. Using array index as key causes stale data bugs when reordering or removing items.

When to Use

  • Building any React form where performance matters (large forms, frequent re-renders).
  • Integrating with third-party UI component libraries (Material UI, Radix, shadcn/ui).
  • Forms with dynamic field arrays (line items, tag lists, nested repeaters).
  • Multi-step wizards where form state persists across steps.
  • Projects already using Zod for API validation that want shared schemas for forms.

Install this skill directly: skilldb add form-survey-services-skills

Get CLI access →