Skip to main content
Technology & EngineeringForm Survey Services249 lines

React Hook Form

Build performant React forms with React Hook Form using register, validation, controllers, and form state management. Covers uncontrolled inputs, schema validation with Zod, controlled components, and TypeScript integration.

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: 249 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 →

Related Skills

Surveymonkey

Integrate SurveyMonkey's REST API to create surveys, collect responses via collectors, process results, and configure webhooks. Covers survey design, collector management, response analysis, and TypeScript client patterns.

Form Survey Services258L

Tally

Integrate Tally forms via its API and embed SDK to handle submissions, configure webhooks, apply conditional logic, and embed forms seamlessly. Covers REST endpoints, webhook payloads, and front-end embedding with TypeScript.

Form Survey Services240L

Typeform

Integrate Typeform APIs to create forms, retrieve responses, configure webhooks, set up logic jumps, and embed forms in web applications. Covers the Create API, Responses API, and front-end embed SDK with TypeScript patterns.

Form Survey Services246L

Zod

Define and validate data schemas with Zod for parsing, transforms, refinements, and form integration. Covers schema composition, type inference, custom error messages, and integration with React Hook Form and tRPC.

Form Survey Services287L

Formbricks

Integrate Formbricks open-source surveys for in-app, website, and link-based feedback collection. Covers the JavaScript SDK for targeted in-app surveys, REST API for response management, and self-hosting configuration with TypeScript.

Form Survey Services246L

Formik

Build React forms with Formik using useFormik hook, Field components, and schema validation via Yup or Zod. Covers form state management, custom field components, async validation, and TypeScript integration patterns.

Form Survey Services279L