git:20260202.b236384 to git:20260316.00cb75b

155 added, 558 removed. Audit A to A.

---
name: web-forms-react-hook-form
description: React Hook Form patterns - useForm, Controller, useFieldArray, validation resolver, performance optimization
---
# React Hook Form Patterns
- > **Quick Guide:** Use React Hook Form for performant, type-safe form handling. Use `register` for native inputs, `Controller` for controlled components, `useFieldArray` for dynamic fields, and resolver pattern for schema validation.
+ > **Quick Guide:** Use `register` for native inputs, `Controller` for controlled components, `useFieldArray` for dynamic fields. Always provide `useForm<FormData>()` generics. Set `mode: "onBlur"` for optimal UX. Use resolver pattern for schema validation. Use `useWatch` instead of `watch()` in render to avoid re-rendering the whole form. Use `field.id` as key in useFieldArray -- never array index.
---
<critical_requirements>
## CRITICAL: Before Using This Skill
> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
**(You MUST provide generic types to `useForm<FormData>()` for type-safe form handling)**
**(You MUST use `field.id` as key prop in useFieldArray - NEVER use array index)**
- **(You MUST use Controller for controlled components like selects, date pickers, and custom inputs)**
+ **(You MUST use Controller for controlled components that don't expose a ref)**
- **(You MUST use resolver pattern for schema validation - defer schema creation to validation skill)**
+ **(You MUST use resolver pattern for schema validation - keep schemas separate from form logic)**
**(You MUST set `mode: "onBlur"` or `mode: "onTouched"` for optimal UX - avoid `mode: "onChange"` unless needed)**
</critical_requirements>
---
- **Auto-detection:** React Hook Form, useForm, register, handleSubmit, formState, Controller, useFieldArray, useWatch, useFormContext, resolver, zodResolver
+ **Auto-detection:** React Hook Form, useForm, register, handleSubmit, formState, Controller, useFieldArray, useWatch, useFormContext, resolver, zodResolver, FormProvider, useFormState, FormStateSubscribe
**When to use:**
- Building forms with validation requirements
- Managing complex form state with many fields
- Creating dynamic forms with add/remove fields
- Integrating with controlled component libraries
- Handling multi-step or wizard forms
- **Key patterns covered:**
-
- - useForm hook with TypeScript generics
- - register vs Controller patterns
- - useFieldArray for dynamic fields
- - Validation with resolver pattern
- - Error handling and display
- - Performance optimization
- - Form reset and default values
-
**When NOT to use:**
- Single input without validation (use useState)
- Server-only forms with server actions (use native form + action)
- Read-only data display (not a form scenario)
- **Detailed Resources:**
+ **Key patterns covered:**
- - For code examples, see [examples/](examples/) folder:
- - [core.md](examples/core.md) - Basic form patterns
- - [controlled-components.md](examples/controlled-components.md) - Controller pattern
- - [validation.md](examples/validation.md) - Zod resolver integration
- - [arrays.md](examples/arrays.md) - useFieldArray for dynamic forms
- - [performance.md](examples/performance.md) - Performance optimization
- - [wizard.md](examples/wizard.md) - Multi-step wizard forms
- - [v7-advanced.md](examples/v7-advanced.md) - v7.46+ features (values prop, Form component, FormStateSubscribe)
- - For decision frameworks and anti-patterns, see [reference.md](reference.md)
+ - useForm hook with TypeScript generics
+ - register vs Controller decision
+ - useFieldArray for dynamic fields
+ - Resolver integration for schema validation
+ - useWatch and useFormState for performance
+ - FormProvider/useFormContext for nested components
+ - Form reset, async data loading, and `values` prop
+ - FormStateSubscribe for targeted re-renders (v7.68+)
---
<philosophy>
## Philosophy
React Hook Form prioritizes performance through uncontrolled inputs and subscription-based updates. Only fields that change re-render, not the entire form. The library isolates form state from component state, minimizing re-renders and keeping forms responsive even with many fields.
**Core Principles:**
1. **Uncontrolled by default** - Use `register` for native inputs to avoid re-renders
- 2. **Controlled when needed** - Use `Controller` for UI library components
- 3. **Schema validation** - Separate validation logic from form logic using resolvers
- 4. **Subscription-based** - Subscribe to only the form state you need
+ 2. **Controlled when needed** - Use `Controller` for UI library components that don't expose a ref
+ 3. **Schema validation via resolver** - Separate validation logic from form logic
+ 4. **Subscription-based** - Subscribe to only the form state you need (`useWatch`, `useFormState`)
5. **Type safety** - Always provide TypeScript generics for form data
</philosophy>
---
<patterns>
## Core Patterns
### Pattern 1: Basic useForm with TypeScript
- Define form data types and pass them as generics to `useForm` for full type safety.
+ Always provide a type parameter, `mode`, and `defaultValues`. These three prevent the most common issues (no type safety, validation noise, undefined warnings).
```typescript
- import { useForm } from "react-hook-form";
- import type { SubmitHandler } from "react-hook-form";
-
- // Define form data interface
- interface ContactFormData {
- name: string;
- email: string;
- message: string;
- }
-
- export function ContactForm() {
- const {
- register,
- handleSubmit,
- formState: { errors, isSubmitting },
- } = useForm<ContactFormData>({
- mode: "onBlur", // Validate on blur for optimal UX
- defaultValues: {
- name: "",
- email: "",
- message: "",
- },
- });
-
- const onSubmit: SubmitHandler<ContactFormData> = async (data) => {
- // data is fully typed as ContactFormData
- await submitForm(data);
- };
-
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <input {...register("name", { required: "Name is required" })} />
- {errors.name && <span>{errors.name.message}</span>}
-
- <input {...register("email", { required: "Email is required" })} />
- {errors.email && <span>{errors.email.message}</span>}
-
- <textarea {...register("message")} />
-
- <button type="submit" disabled={isSubmitting}>
- Submit
- </button>
- </form>
- );
- }
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ } = useForm<ContactFormData>({
+ mode: "onBlur",
+ defaultValues: { name: "", email: "", message: "" },
+ });
```
- **Why good:** TypeScript generics provide autocomplete and type checking for field names, mode: "onBlur" validates only when user leaves field reducing noise, defaultValues initializes form state preventing undefined values, SubmitHandler type ensures onSubmit receives correctly typed data
+ **Why this matters:** Without generics, field names are `any`. Without `defaultValues`, values are `undefined` and cause hydration mismatches. Without `mode: "onBlur"`, the default `"onSubmit"` gives no feedback until first submit.
+ See [examples/core.md](examples/core.md) for complete form with accessibility attributes and error display.
+
---
### Pattern 2: Controller for Controlled Components
- Use `Controller` when working with UI library components (selects, date pickers, etc.) that don't expose a ref.
+ Use `Controller` when a component doesn't expose a native ref (custom selects, date pickers, rich text editors). Use `register` for standard HTML inputs.
```typescript
- import { useForm, Controller } from "react-hook-form";
- import type { SubmitHandler } from "react-hook-form";
- // Your UI components (styling handled by your styling skill)
- import { Select, DatePicker } from "./ui";
-
- interface BookingFormData {
- service: string;
- date: Date | null;
- notes: string;
- }
-
- export function BookingForm() {
- const { control, register, handleSubmit, formState: { errors } } = useForm<BookingFormData>({
- mode: "onBlur",
- defaultValues: {
- service: "",
- date: null,
- notes: "",
- },
- });
-
- const onSubmit: SubmitHandler<BookingFormData> = async (data) => {
- await createBooking(data);
- };
-
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- {/* Controller for controlled components */}
- <Controller
- name="service"
- control={control}
- rules={{ required: "Service is required" }}
- render={({ field, fieldState: { error } }) => (
- <>
- <Select
- {...field}
- options={serviceOptions}
- placeholder="Select a service"
- />
- {error && <span>{error.message}</span>}
- </>
- )}
- />
-
- <Controller
- name="date"
- control={control}
- rules={{ required: "Date is required" }}
- render={({ field, fieldState: { error } }) => (
- <>
- <DatePicker
- selected={field.value}
- onChange={field.onChange}
- onBlur={field.onBlur}
- />
- {error && <span>{error.message}</span>}
- </>
- )}
- />
-
- {/* register for native inputs */}
- <textarea {...register("notes")} />
-
- <button type="submit">Book</button>
- </form>
- );
- }
+ <Controller
+ name="service"
+ control={control}
+ rules={{ required: "Service is required" }}
+ render={({ field, fieldState: { error } }) => (
+ <>
+ <Select {...field} options={serviceOptions} />
+ {error && <span role="alert">{error.message}</span>}
+ </>
+ )}
+ />
```
- **Why good:** Controller wraps controlled components without breaking RHF optimization, render prop provides field props (value, onChange, onBlur) and fieldState for errors, mixing register and Controller in same form works seamlessly
+ **Key decision:** If the component accepts a `ref` prop that forwards to a native input, `register` works. Otherwise, use `Controller`.
+ See [examples/controlled-components.md](examples/controlled-components.md) for single select, date picker, and multi-select checkbox patterns.
+
---
### Pattern 3: useFieldArray for Dynamic Fields
- Use `useFieldArray` for forms with repeatable field groups. Always use `field.id` as the key.
+ Use `useFieldArray` for repeatable field groups. **Always use `field.id` as the React key** -- array index causes state corruption on add/remove.
```typescript
- import { useForm, useFieldArray } from "react-hook-form";
- import type { SubmitHandler } from "react-hook-form";
-
- interface OrderItem {
- productId: string;
- quantity: number;
- }
-
- interface OrderFormData {
- customerName: string;
- items: OrderItem[];
- }
-
- const MIN_QUANTITY = 1;
- const DEFAULT_QUANTITY = 1;
-
- export function OrderForm() {
- const {
- control,
- register,
- handleSubmit,
- formState: { errors },
- } = useForm<OrderFormData>({
- mode: "onBlur",
- defaultValues: {
- customerName: "",
- items: [{ productId: "", quantity: DEFAULT_QUANTITY }],
- },
- });
-
- const { fields, append, remove } = useFieldArray({
- control,
- name: "items",
- });
-
- const onSubmit: SubmitHandler<OrderFormData> = async (data) => {
- await createOrder(data);
- };
-
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <input {...register("customerName", { required: "Name is required" })} />
-
- {fields.map((field, index) => (
- // CRITICAL: Use field.id as key, NEVER use index
- <div key={field.id}>
- <input
- {...register(`items.${index}.productId`, { required: "Product required" })}
- placeholder="Product ID"
- />
- <input
- type="number"
- {...register(`items.${index}.quantity`, {
- valueAsNumber: true,
- min: { value: MIN_QUANTITY, message: `Min quantity is ${MIN_QUANTITY}` },
- })}
- />
- <button type="button" onClick={() => remove(index)}>
- Remove
- </button>
- </div>
- ))}
-
- <button
- type="button"
- onClick={() => append({ productId: "", quantity: DEFAULT_QUANTITY })}
- >
- Add Item
- </button>
+ const { fields, append, remove } = useFieldArray({ control, name: "items" });
- <button type="submit">Submit Order</button>
- </form>
- );
- }
+ {fields.map((field, index) => (
+ <div key={field.id}> {/* CRITICAL: field.id, never index */}
+ <input {...register(`items.${index}.name`)} />
+ <button type="button" onClick={() => remove(index)}>Remove</button>
+ </div>
+ ))}
```
- **Why good:** field.id ensures React can track items correctly during add/remove operations, valueAsNumber converts string input to number automatically, named constants for MIN_QUANTITY and DEFAULT_QUANTITY prevent magic numbers
+ **Gotcha:** `append`/`prepend`/`insert` require complete objects (not partial). Use `rules.minLength` on `useFieldArray` for minimum item validation. Array-level errors live at `errors.items.root`.
+ See [examples/arrays.md](examples/arrays.md) for a complete invoice form with calculated totals.
+
---
- ### Pattern 4: Resolver Pattern for Schema Validation
+ ### Pattern 4: Resolver for Schema Validation
- Use resolvers to integrate external validation libraries. Define schemas in your validation skill, use them here.
+ Use `resolver` to integrate validation schemas. The resolver handles validation; you wire it to the form. Keep schema definition separate from form code.
```typescript
- import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
- import type { SubmitHandler } from "react-hook-form";
- import type { z } from "zod";
- // Schema defined in validation skill - import from your validation module
- import { userProfileSchema } from "./schemas/user-profile";
- // Infer type from schema
- type UserProfileFormData = z.infer<typeof userProfileSchema>;
-
- export function UserProfileForm() {
- const {
- register,
- handleSubmit,
- formState: { errors, isValid, isDirty },
- } = useForm<UserProfileFormData>({
- resolver: zodResolver(userProfileSchema),
- mode: "onBlur",
- defaultValues: {
- username: "",
- email: "",
- bio: "",
- },
- });
-
- const onSubmit: SubmitHandler<UserProfileFormData> = async (data) => {
- // data is validated and typed
- await updateProfile(data);
- };
-
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <input {...register("username")} />
- {errors.username && <span>{errors.username.message}</span>}
-
- <input {...register("email")} />
- {errors.email && <span>{errors.email.message}</span>}
-
- <textarea {...register("bio")} />
- {errors.bio && <span>{errors.bio.message}</span>}
-
- <button type="submit" disabled={!isDirty || !isValid}>
- Save Profile
- </button>
- </form>
- );
- }
+ const { register, handleSubmit } = useForm<FormData>({
+ resolver: zodResolver(schema),
+ mode: "onBlur",
+ defaultValues: { username: "", email: "" },
+ });
```
- **Why good:** resolver separates validation logic from form logic, z.infer automatically generates TypeScript types from schema, isDirty and isValid enable smart submit button state, schema is reusable across forms and testable independently
+ **Why resolver over inline rules:** Schemas are testable independently, reusable across forms, support cross-field validation (e.g. confirmPassword), and generate TypeScript types via `z.infer`.
- **Note:** Schema creation patterns are covered in the Zod validation skill. This skill focuses on how to integrate schemas with React Hook Form.
+ See [examples/validation.md](examples/validation.md) for resolver integration with a registration form.
---
- ### Pattern 5: useWatch for Reactive Fields
+ ### Pattern 5: useWatch for Reactive Derived Values
- Use `useWatch` to subscribe to specific field values without re-rendering the entire form.
+ Use `useWatch` in a separate component to subscribe to specific fields without re-rendering the entire form. Prefer `useWatch` over `watch()` in render.
```typescript
- import { useForm, useWatch } from "react-hook-form";
- import type { SubmitHandler } from "react-hook-form";
-
- interface PricingFormData {
- plan: "basic" | "pro" | "enterprise";
- seats: number;
- billingCycle: "monthly" | "annual";
- }
-
- const PLAN_PRICES = {
- basic: 10,
- pro: 25,
- enterprise: 50,
- } as const;
-
- const ANNUAL_DISCOUNT = 0.2;
-
- export function PricingForm() {
- const { control, register, handleSubmit } = useForm<PricingFormData>({
- mode: "onBlur",
- defaultValues: {
- plan: "basic",
- seats: 1,
- billingCycle: "monthly",
- },
- });
-
- // Subscribe to specific fields for price calculation
- const [plan, seats, billingCycle] = useWatch({
- control,
- name: ["plan", "seats", "billingCycle"],
- });
-
- const calculatePrice = () => {
- const basePrice = PLAN_PRICES[plan] * seats;
- return billingCycle === "annual"
- ? basePrice * 12 * (1 - ANNUAL_DISCOUNT)
- : basePrice;
- };
-
- const onSubmit: SubmitHandler<PricingFormData> = async (data) => {
- await createSubscription(data);
- };
-
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <select {...register("plan")}>
- <option value="basic">Basic</option>
- <option value="pro">Pro</option>
- <option value="enterprise">Enterprise</option>
- </select>
-
- <input type="number" {...register("seats", { valueAsNumber: true })} />
-
- <select {...register("billingCycle")}>
- <option value="monthly">Monthly</option>
- <option value="annual">Annual (20% off)</option>
- </select>
-
- {/* Price display updates when watched fields change */}
- <div>Total: ${calculatePrice()}</div>
-
- <button type="submit">Subscribe</button>
- </form>
- );
+ function PriceDisplay({ control }: { control: Control<PricingFormData> }) {
+ const [plan, seats] = useWatch({ control, name: ["plan", "seats"] });
+ return <div>Total: ${PLAN_PRICES[plan] * seats}</div>;
}
```
- **Why good:** useWatch subscribes to only specified fields minimizing re-renders, named constants for PLAN_PRICES and ANNUAL_DISCOUNT improve maintainability, price calculation is derived from form state without separate useState
-
- ---
-
- ### Pattern 6: useFormContext for Nested Components
-
- Use `FormProvider` and `useFormContext` to access form methods in nested components without prop drilling.
+ **v7.61+ `compute` option:** Transform watched values before subscription -- component only re-renders when the computed result changes.
```typescript
- import { useForm, FormProvider, useFormContext } from "react-hook-form";
- import type { SubmitHandler } from "react-hook-form";
-
- interface CheckoutFormData {
- email: string;
- shippingAddress: {
- street: string;
- city: string;
- zip: string;
- };
- billingAddress: {
- street: string;
- city: string;
- zip: string;
- };
- }
-
- // Parent form component
- export function CheckoutForm() {
- const methods = useForm<CheckoutFormData>({
- mode: "onBlur",
- defaultValues: {
- email: "",
- shippingAddress: { street: "", city: "", zip: "" },
- billingAddress: { street: "", city: "", zip: "" },
- },
- });
-
- const onSubmit: SubmitHandler<CheckoutFormData> = async (data) => {
- await processCheckout(data);
- };
-
- return (
- <FormProvider {...methods}>
- <form onSubmit={methods.handleSubmit(onSubmit)}>
- <input {...methods.register("email")} placeholder="Email" />
-
- <AddressFields prefix="shippingAddress" title="Shipping Address" />
- <AddressFields prefix="billingAddress" title="Billing Address" />
-
- <button type="submit">Complete Checkout</button>
- </form>
- </FormProvider>
- );
- }
-
- // Nested component using useFormContext
- interface AddressFieldsProps {
- prefix: "shippingAddress" | "billingAddress";
- title: string;
- }
-
- function AddressFields({ prefix, title }: AddressFieldsProps) {
- const { register, formState: { errors } } = useFormContext<CheckoutFormData>();
-
- return (
- <fieldset>
- <legend>{title}</legend>
- <input
- {...register(`${prefix}.street`)}
- placeholder="Street"
- />
- {errors[prefix]?.street && <span>{errors[prefix]?.street?.message}</span>}
-
- <input
- {...register(`${prefix}.city`)}
- placeholder="City"
- />
-
- <input
- {...register(`${prefix}.zip`)}
- placeholder="ZIP Code"
- />
- </fieldset>
- );
- }
+ const total = useWatch({
+ control,
+ compute: ({ plan, seats, billingCycle }) => {
+ const base = PLAN_PRICES[plan] * seats;
+ return billingCycle === "annual" ? base * 12 * (1 - ANNUAL_DISCOUNT) : base;
+ },
+ });
```
- **Why good:** FormProvider eliminates prop drilling in complex forms, useFormContext gives nested components access to all form methods, prefix pattern enables reusable address components, TypeScript ensures type safety in nested paths
+ See [examples/v7-advanced.md](examples/v7-advanced.md) Pattern 7 for complete compute example.
---
- ### Pattern 7: Form Reset and Default Values
-
- Handle form reset properly, especially when loading async data.
+ ### Pattern 6: useFormContext for Nested Components
- **Note:** For reactive async data, consider using the `values` prop instead of manual reset. See [v7-advanced.md](examples/v7-advanced.md) Pattern 1 for the modern approach.
+ Use `FormProvider` + `useFormContext` to share form methods across deeply nested components without prop drilling. Ideal for multi-section forms and wizard steps.
```typescript
- import { useForm } from "react-hook-form";
- import { useEffect } from "react";
- import type { SubmitHandler } from "react-hook-form";
+ // Parent
+ <FormProvider {...methods}>
+ <form onSubmit={methods.handleSubmit(onSubmit)}>
+ <AddressFields prefix="shippingAddress" />
+ <AddressFields prefix="billingAddress" />
+ </form>
+ </FormProvider>
- interface UserSettingsFormData {
- displayName: string;
- email: string;
- notifications: boolean;
+ // Child - no props needed
+ function AddressFields({ prefix }) {
+ const { register } = useFormContext<CheckoutFormData>();
+ return <input {...register(`${prefix}.street`)} />;
}
+ ```
- interface UserSettingsFormProps {
- userId: string;
- }
+ **When to use:** 3+ levels of nesting or reusable form sections. For 1-2 levels, passing `control`/`register` as props is simpler.
- export function UserSettingsForm({ userId }: UserSettingsFormProps) {
- const {
- register,
- handleSubmit,
- reset,
- formState: { errors, isDirty, isSubmitting },
- } = useForm<UserSettingsFormData>({
- mode: "onBlur",
- defaultValues: {
- displayName: "",
- email: "",
- notifications: true,
- },
- });
+ See [examples/wizard.md](examples/wizard.md) for a complete multi-step wizard using FormProvider with per-step validation via `trigger()`.
- // Load user data and reset form
- useEffect(() => {
- const loadUserSettings = async () => {
- const settings = await fetchUserSettings(userId);
- // Reset form with fetched data
- reset({
- displayName: settings.displayName,
- email: settings.email,
- notifications: settings.notifications,
- });
- };
- loadUserSettings();
- }, [userId, reset]);
+ ---
- const onSubmit: SubmitHandler<UserSettingsFormData> = async (data) => {
- await updateUserSettings(userId, data);
- // Reset form state but keep values (clears isDirty)
- reset(data);
- };
+ ### Pattern 7: Form Reset and Async Data
- const handleCancel = () => {
- // Reset to last saved values
- reset();
- };
+ **Two approaches for loading external data into a form:**
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <input {...register("displayName", { required: "Required" })} />
- {errors.displayName && <span>{errors.displayName.message}</span>}
+ 1. **`values` prop (v7.x+, preferred):** Reactively updates form when external data changes. Pair with `resetOptions: { keepDirtyValues: true }` to preserve user edits.
- <input {...register("email", { required: "Required" })} />
- {errors.email && <span>{errors.email.message}</span>}
+ 2. **`reset()` in useEffect (legacy):** Manually reset when data arrives. Use `reset(data)` which updates both values AND defaultValues for proper `isDirty` tracking.
- <label>
- <input type="checkbox" {...register("notifications")} />
- Email notifications
- </label>
+ ```typescript
+ // Modern: values prop (reactive, auto-updates)
+ useForm<FormData>({
+ values: userData,
+ resetOptions: { keepDirtyValues: true },
+ });
- <div>
- <button type="button" onClick={handleCancel} disabled={!isDirty}>
- Cancel
- </button>
- <button type="submit" disabled={!isDirty || isSubmitting}>
- Save
- </button>
- </div>
- </form>
- );
- }
+ // Legacy: manual reset
+ useEffect(() => {
+ if (data) reset(data);
+ }, [data, reset]);
```
- **Why good:** reset() with data updates both values and defaultValues for proper isDirty tracking, useEffect loads async data and resets form once data arrives, reset() without args reverts to defaultValues enabling cancel functionality, isDirty enables smart button states
+ **Cancel/save pattern:** `reset()` without args reverts to defaultValues. After save, call `reset(data)` to update defaultValues and clear `isDirty`.
+ See [examples/v7-advanced.md](examples/v7-advanced.md) Pattern 1 for `values` prop with async data.
+
---
- ### Pattern 8: Error Display Component
+ ### Pattern 8: Isolated Error Display
- Create a reusable error display component using `useFormState` for optimized re-renders.
+ Use `useFormState` with `name` to create error components that only re-render when their specific field's error changes. For v7.68+, `FormStateSubscribe` provides the same isolation as a component.
```typescript
- import { useFormState } from "react-hook-form";
- import type { Control, FieldPath, FieldValues } from "react-hook-form";
-
- interface FieldErrorProps<T extends FieldValues> {
- control: Control<T>;
- name: FieldPath<T>;
- className?: string;
- }
-
- export function FieldError<T extends FieldValues>({
- control,
- name,
- className,
- }: FieldErrorProps<T>) {
- // Subscribe only to errors for this specific field
+ function FieldError<T extends FieldValues>({ control, name }: Props<T>) {
const { errors } = useFormState({ control, name });
-
const error = errors[name];
if (!error) return null;
-
- return (
- <span role="alert" className={className}>
- {error.message as string}
- </span>
- );
- }
-
- // Usage
- interface LoginFormData {
- email: string;
- password: string;
+ return <span role="alert">{error.message as string}</span>;
}
-
- function LoginForm() {
- const { control, register, handleSubmit } = useForm<LoginFormData>({
- mode: "onBlur",
- });
-
- const onSubmit: SubmitHandler<LoginFormData> = async (data) => {
- await login(data);
- };
+ ```
- return (
- <form onSubmit={handleSubmit(onSubmit)}>
- <input {...register("email")} />
- {/* Only this component re-renders when email error changes */}
- <FieldError control={control} name="email" />
+ See [examples/performance.md](examples/performance.md) for a complete large form with isolated subscriptions, and [examples/v7-advanced.md](examples/v7-advanced.md) Pattern 4 for `FormStateSubscribe`.
- <input {...register("password")} type="password" />
- <FieldError control={control} name="password" />
+ </patterns>
- <button type="submit">Log In</button>
- </form>
- );
- }
- ```
+ ---
- **Why good:** useFormState with name prop subscribes only to that field's errors, role="alert" ensures screen readers announce error, generic types make component reusable across forms, className prop allows styling customization
+ **Detailed Resources:**
- </patterns>
+ - [examples/core.md](examples/core.md) - Basic form with accessibility and error display
+ - [examples/controlled-components.md](examples/controlled-components.md) - Controller for select, date picker, multi-select
+ - [examples/validation.md](examples/validation.md) - Resolver integration with Zod
+ - [examples/arrays.md](examples/arrays.md) - useFieldArray with invoice line items
+ - [examples/performance.md](examples/performance.md) - Isolated subscriptions for large forms
+ - [examples/wizard.md](examples/wizard.md) - Multi-step wizard with per-step validation
+ - [examples/v7-advanced.md](examples/v7-advanced.md) - values prop, Form component, FormStateSubscribe, compute
+ - [reference.md](reference.md) - Decision frameworks, checklists, anti-patterns
---
- <integration>
+ <red_flags>
- ## Integration Guide
+ ## RED FLAGS
- **React Hook Form is validation-library agnostic.** Use the resolver pattern to integrate with any validation library.
+ **High Priority Issues:**
- **Works with:**
+ - Using array index as key in useFieldArray -- causes state corruption on add/remove/reorder
+ - Missing TypeScript generics on `useForm` -- loses type safety for field names and values
+ - Using `register` for components that don't expose ref -- use Controller instead
+ - Not providing `defaultValues` -- causes hydration mismatches and undefined warnings
- - **Validation libraries** (via resolvers): Zod, Yup, Joi, Vest, ArkType, Valibot
- - **UI component libraries**: Material UI, Ant Design, Chakra UI (use Controller)
- - **Native HTML inputs**: Use register directly
- - Any React component library via Controller
+ **Medium Priority Issues:**
- **Form validation approach:**
+ - Using `mode: "onChange"` without reason -- validates on every keystroke, noisy UX
+ - Destructuring many `formState` properties -- subscribes to all, causes unnecessary re-renders
+ - Using `watch()` in render body -- triggers re-render on every field change; use `useWatch` instead
+ - Calling `setValue` without `shouldValidate: true` -- may leave form in invalid state
+ - Not using `trigger(fieldNames)` for step validation in wizard forms
- - Schema validation is handled by your validation skill (Zod, Yup, etc.)
- - This skill covers how to wire schemas to forms via resolver
- - Keep validation logic separate from form logic
+ **Gotchas & Edge Cases:**
- </integration>
+ - `reset()` reverts to defaultValues; `reset(newData)` updates both values AND defaultValues
+ - `handleSubmit` does not catch errors thrown in your `onSubmit` callback -- handle errors yourself with try/catch
+ - `append`/`prepend`/`insert` require complete objects, not partial data
+ - Array-level errors live at `errors.arrayName.root`, item errors at `errors.arrayName[index].fieldName`
+ - `shouldUnregister: true` removes unmounted field values -- keep `false` (default) for wizard forms
+ - `useWatch` returns `defaultValue` on first render before subscription kicks in
+ - `setValue` does not directly update `useFieldArray` -- use `replace()` API instead
+ - `FormStateSubscribe` requires `FormProvider` wrapping the form
+ - `values` prop (reactive external data) vs `defaultValues` (static initial values) -- do not mix their use cases
+ </red_flags>
+
---
<critical_reminders>
## CRITICAL REMINDERS
> **All code must follow project conventions in CLAUDE.md**
**(You MUST provide generic types to `useForm<FormData>()` for type-safe form handling)**
**(You MUST use `field.id` as key prop in useFieldArray - NEVER use array index)**
- **(You MUST use Controller for controlled components like selects, date pickers, and custom inputs)**
+ **(You MUST use Controller for controlled components that don't expose a ref)**
- **(You MUST use resolver pattern for schema validation - defer schema creation to validation skill)**
+ **(You MUST use resolver pattern for schema validation - keep schemas separate from form logic)**
**(You MUST set `mode: "onBlur"` or `mode: "onTouched"` for optimal UX - avoid `mode: "onChange"` unless needed)**
**Failure to follow these rules will break form validation, cause re-render issues, and reduce type safety.**
</critical_reminders>