React Hook Form
Build performant React forms with React Hook Form using register, validation,
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 linesReact 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
Controllerfor native HTML inputs.registeris lighter and avoids unnecessary re-renders. - Spreading
formStatewithout destructuring. Accessing the full object subscribes to all state changes, defeating RHF's render optimization. - Defining validation in both the schema and
registeroptions. Pick one source of truth — prefer the schema resolver. - Not passing
key={field.id}inuseFieldArraymaps. 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
Related Skills
Formbricks
Integrate Formbricks open-source surveys for in-app, website, and link-based
Formik
Build React forms with Formik using useFormik hook, Field components, and
Jotform
Integrate JotForm's REST API to create forms, retrieve submissions, process
Surveymonkey
Integrate SurveyMonkey's REST API to create surveys, collect responses via
Tally
Integrate Tally forms via its API and embed SDK to handle submissions, configure
Typeform
Integrate Typeform APIs to create forms, retrieve responses, configure webhooks,