references/adv-devtools-performance.md
---
title: Disable DevTools in Production and During Performance Testing
impact: LOW
impactDescription: eliminates DevTools overhead during profiling
tags: adv, devtools, performance, debugging
---
## Disable DevTools in Production and During Performance Testing
React Hook Form DevTools can cause performance issues, especially with FormProvider. Always disable in production and temporarily remove when profiling performance.
**Incorrect (DevTools enabled regardless of environment):**
```typescript
import { DevTool } from '@hookform/devtools'
function ProfileForm() {
const { control, register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<DevTool control={control} /> {/* Always renders, even in production */}
</form>
)
}
```
**Correct (conditionally render DevTools):**
```typescript
import { DevTool } from '@hookform/devtools'
function ProfileForm() {
const { control, register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{process.env.NODE_ENV === 'development' && <DevTool control={control} />}
</form>
)
}
```
**Alternative (dynamic import to avoid bundle impact):**
```typescript
const DevTool = lazy(() =>
import('@hookform/devtools').then((mod) => ({ default: mod.DevTool }))
)
function ProfileForm() {
const { control, register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{process.env.NODE_ENV === 'development' && (
<Suspense fallback={null}>
<DevTool control={control} />
</Suspense>
)}
</form>
)
}
```
Reference: [React Hook Form DevTools](https://react-hook-form.com/dev-tools)
references/adv-formprovider-memo.md
---
title: Memoize FormProvider Children Only After Profiling
impact: LOW
impactDescription: prevents cascade re-renders from FormProvider state updates
tags: adv, FormProvider, memo, optimization
---
## Memoize FormProvider Children Only After Profiling
`FormProvider` exposes form methods through context. `React.memo` can skip parent-prop re-renders, but it does not block updates for context values a child consumes. Subscribe as deep and narrowly as possible with `useWatch`, `useFormState({ name, exact })`, or `useController`; add memoization only after profiling.
**Incorrect (children re-render on any form state change):**
```typescript
function LargeForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PersonalInfoSection /> {/* Re-renders on ANY form state change */}
<AddressSection /> {/* Re-renders on ANY form state change */}
<PaymentSection /> {/* Re-renders on ANY form state change */}
</form>
</FormProvider>
)
}
function PersonalInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('firstName')} />
<input {...register('lastName')} />
</div>
)
}
```
**Correct (memo prevents unnecessary child re-renders):**
```typescript
function LargeForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PersonalInfoSection />
<AddressSection />
<PaymentSection />
</form>
</FormProvider>
)
}
const PersonalInfoSection = memo(function PersonalInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('firstName')} />
<input {...register('lastName')} />
</div>
)
})
const AddressSection = memo(function AddressSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('address.street')} />
<input {...register('address.city')} />
</div>
)
})
```
Reference: [React Hook Form - Advanced Usage](https://react-hook-form.com/advanced-usage)
references/adv-opaque-types.md
---
title: Register Value Classes in OpaqueTypes
impact: LOW
impactDescription: stops Path and DeepPartial from walking into Dayjs, Decimal or Money objects, removing bogus field paths and "Excessive complexity" type errors
tags: adv, typescript, OpaqueTypes, path, performance
---
## Register Value Classes in OpaqueTypes
React Hook Form's path types (`Path`, `DeepPartial`, `FieldErrorsImpl` …) recurse into every object in your form values. A `Dayjs`, `Decimal` or custom `Money` field then produces paths such as `price.cents`, and large classes can trigger TypeScript "Excessive complexity" errors. Since 7.87, you can declare such types opaque once, so the form treats them as leaf values.
**Incorrect (the class is walked like a nested form object):**
```typescript
class Money {
constructor(readonly cents: number, readonly currency: string) {}
}
type PriceForm = { title: string; price: Money }
// Compiles, but 'price.cents' is not a real field: the value is replaced as a whole
register('price.cents')
```
**Correct (declare it opaque once, e.g. in a shared types file):**
```typescript
import type { Path } from 'react-hook-form'
class Money {
constructor(readonly cents: number, readonly currency: string) {}
}
declare module 'react-hook-form' {
interface OpaqueTypes {
money: Money
}
}
type PriceForm = { title: string; price: Money }
const ok: Path<PriceForm> = 'price'
// @ts-expect-error: 'price.cents' is no longer a form path
const bad: Path<PriceForm> = 'price.cents'
```
With an empty registry nothing changes, so adding it is safe.
**When NOT to use this pattern:**
- Plain nested objects whose fields are separate inputs (`address.street`): they must stay walkable
Reference: [v7.87.0 release](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.87.0) · [TypeScript support](https://react-hook-form.com/ts)
references/adv-testing-wrapper.md
---
title: Create Test Wrapper with QueryClient and AuthProvider
impact: LOW
impactDescription: enables proper hook testing with required context providers
tags: adv, testing, wrapper, vitest, react-testing-library
---
## Create Test Wrapper with QueryClient and AuthProvider
Hook tests require proper context providers. Create a reusable wrapper function that provides QueryClient, AuthProvider, and any other required context for your forms.
**Incorrect (missing providers causes hook errors):**
```typescript
import { renderHook } from '@testing-library/react'
import { useForm } from 'react-hook-form'
test('form submits correctly', () => {
const { result } = renderHook(() => useForm()) // May fail if form uses context
act(() => {
result.current.setValue('email', 'test@example.com')
})
expect(result.current.getValues('email')).toBe('test@example.com')
})
```
**Correct (wrapper provides all required context):**
```typescript
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
test('form submits correctly', () => {
const { result } = renderHook(() => useForm(), {
wrapper: createWrapper(),
})
act(() => {
result.current.setValue('email', 'test@example.com')
})
expect(result.current.getValues('email')).toBe('test@example.com')
})
```
Reference: [React Hook Form - Testing](https://react-hook-form.com/advanced-usage#TestingForm)
references/array-complete-default-objects.md
---
title: Provide Complete Default Objects for Field Array Operations
impact: HIGH
impactDescription: prevents partial data and validation failures
tags: array, useFieldArray, append, default-values
---
## Provide Complete Default Objects for Field Array Operations
When using `append()`, `prepend()`, `insert()`, or `update()`, always provide complete field objects with all required properties. Empty or partial objects cause validation and data inconsistencies.
**Incorrect (empty object causes undefined fields):**
```typescript
function TasksForm() {
const { control, register } = useForm<{ tasks: Task[] }>()
const { fields, append } = useFieldArray({ control, name: 'tasks' })
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tasks.${index}.title`)} /> {/* undefined initially */}
<input {...register(`tasks.${index}.priority`)} /> {/* undefined initially */}
</div>
))}
<button type="button" onClick={() => append({})}>Add Task</button> {/* Empty object */}
</div>
)
}
```
**Correct (complete object with all fields):**
```typescript
function TasksForm() {
const { control, register } = useForm<{ tasks: Task[] }>()
const { fields, append } = useFieldArray({ control, name: 'tasks' })
const addTask = () => {
append({
title: '',
priority: 'medium',
dueDate: null,
})
}
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tasks.${index}.title`)} />
<select {...register(`tasks.${index}.priority`)}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
))}
<button type="button" onClick={addTask}>Add Task</button>
</div>
)
}
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
references/array-separate-crud-operations.md
---
title: Separate Sequential Field Array Operations
impact: MEDIUM-HIGH
impactDescription: prevents state corruption from batched mutations
tags: array, useFieldArray, append, remove, sequential
---
## Separate Sequential Field Array Operations
Chaining `append()` and `remove()` in the same handler can cause state corruption. Defer removals to a useEffect or separate user action to allow React to process renders between operations.
**Incorrect (stacked operations cause state issues):**
```typescript
function ReplaceItemForm() {
const { control } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const replaceItem = (indexToReplace: number, newItem: Item) => {
remove(indexToReplace) // Remove old item
append(newItem) // Immediately add new - state may be stale
}
return (
<div>
{fields.map((field, index) => (
<ItemRow
key={field.id}
index={index}
onReplace={(newItem) => replaceItem(index, newItem)}
/>
))}
</div>
)
}
```
**Correct (use update for replacements, or defer operations):**
```typescript
function ReplaceItemForm() {
const { control } = useForm()
const { fields, update } = useFieldArray({ control, name: 'items' })
const replaceItem = (indexToReplace: number, newItem: Item) => {
update(indexToReplace, newItem) // Single atomic operation
}
return (
<div>
{fields.map((field, index) => (
<ItemRow
key={field.id}
index={index}
onReplace={(newItem) => replaceItem(index, newItem)}
/>
))}
</div>
)
}
```
**Alternative (defer removal with useEffect):**
```typescript
const [pendingRemoval, setPendingRemoval] = useState<number | null>(null)
useEffect(() => {
if (pendingRemoval !== null) {
remove(pendingRemoval)
setPendingRemoval(null)
}
}, [pendingRemoval, remove])
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
references/array-unique-fieldarray-per-name.md
---
title: Use Single useFieldArray Instance Per Field Name
impact: MEDIUM-HIGH
impactDescription: prevents state conflicts from duplicate subscriptions
tags: array, useFieldArray, instance, state-management
---
## Use Single useFieldArray Instance Per Field Name
Each field name should have only one useFieldArray instance. Multiple instances managing the same field name cause state conflicts and unpredictable behavior.
**Incorrect (multiple instances for same field):**
```typescript
function OrderForm() {
const { control } = useForm()
return (
<div>
<ItemsList control={control} />
<ItemsSummary control={control} />
</div>
)
}
function ItemsList({ control }: { control: Control }) {
const { fields, append } = useFieldArray({ control, name: 'items' }) // Instance 1
return <div>{/* render items */}</div>
}
function ItemsSummary({ control }: { control: Control }) {
const { fields } = useFieldArray({ control, name: 'items' }) // Instance 2 - conflicts!
return <div>Total items: {fields.length}</div>
}
```
**Correct (single instance, pass fields down or use useWatch):**
```typescript
function OrderForm() {
const { control } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
return (
<div>
<ItemsList fields={fields} append={append} remove={remove} />
<ItemsSummary control={control} /> {/* Uses useWatch, not useFieldArray */}
</div>
)
}
function ItemsSummary({ control }: { control: Control }) {
const items = useWatch({ control, name: 'items' }) // Read-only subscription
return <div>Total items: {items?.length ?? 0}</div>
}
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
references/array-use-field-id-as-key.md
---
title: Use field.id as Key in useFieldArray Maps
impact: MEDIUM-HIGH
impactDescription: prevents state corruption and unnecessary re-renders
tags: array, useFieldArray, key, react-key
---
## Use field.id as Key in useFieldArray Maps
useFieldArray generates a unique `id` for each field. Using array index as key causes React to lose track of component identity when items are reordered, removed, or inserted.
**Incorrect (index as key causes state corruption):**
```typescript
function IngredientsForm() {
const { control, register } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' })
return (
<div>
{fields.map((field, index) => (
<div key={index}> {/* Index key causes re-render issues */}
<input {...register(`ingredients.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>
</div>
)
}
```
**Correct (field.id ensures stable identity):**
```typescript
function IngredientsForm() {
const { control, register } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' })
return (
<div>
{fields.map((field, index) => (
<div key={field.id}> {/* Stable identity across operations */}
<input {...register(`ingredients.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>
</div>
)
}
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
references/array-virtualization-formprovider.md
---
title: Use FormProvider for Virtualized Field Arrays
impact: MEDIUM
impactDescription: maintains field state when rows exit/enter viewport
tags: array, useFieldArray, virtualization, FormProvider
---
## Use FormProvider for Virtualized Field Arrays
When using virtualization libraries (react-window, react-virtuoso) with field arrays, fields exiting the viewport lose their DOM reference. Use FormProvider with useFormContext to maintain state across virtualization boundaries.
**Incorrect (direct props break with virtualization):**
```typescript
function VirtualizedList() {
const { control, register } = useForm()
const { fields } = useFieldArray({ control, name: 'rows' })
return (
<VirtualList
itemCount={fields.length}
itemSize={50}
renderItem={({ index }) => (
<input {...register(`rows.${index}.value`)} /> // Loses state when scrolled out
)}
/>
)
}
```
**Correct (FormProvider preserves context across virtualization):**
```typescript
function VirtualizedList() {
const methods = useForm()
const { fields } = useFieldArray({ control: methods.control, name: 'rows' })
return (
<FormProvider {...methods}>
<VirtualList
itemCount={fields.length}
itemSize={50}
renderItem={({ index }) => (
<VirtualizedRow index={index} fieldId={fields[index].id} />
)}
/>
</FormProvider>
)
}
function VirtualizedRow({ index, fieldId }: { index: number; fieldId: string }) {
const { register, getValues } = useFormContext()
return (
<input
key={fieldId}
defaultValue={getValues(`rows.${index}.value`)} // Restore from form state
{...register(`rows.${index}.value`)}
/>
)
}
```
Reference: [React Hook Form - Advanced Usage](https://react-hook-form.com/advanced-usage)
references/ctrl-avoid-double-registration.md
---
title: Avoid Double Registration with useController
impact: HIGH
impactDescription: prevents duplicate state management and validation bugs
tags: ctrl, useController, register, double-registration
---
## Avoid Double Registration with useController
useController handles field registration automatically. Calling `register()` on a field already managed by useController creates duplicate state tracking and validation conflicts.
**Incorrect (double registration causes state conflicts):**
```typescript
function CustomInput({ control, name }: CustomInputProps) {
const { register } = useFormContext()
const { field, fieldState } = useController({ name, control })
return (
<div>
<input
{...field}
{...register(name)} // Double registration!
/>
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)
}
```
**Correct (useController handles registration):**
```typescript
function CustomInput({ control, name }: CustomInputProps) {
const { field, fieldState } = useController({ name, control })
return (
<div>
<input {...field} /> {/* useController provides all needed props */}
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)
}
```
Reference: [useController](https://react-hook-form.com/docs/usecontroller)
references/ctrl-controller-field-props.md
---
title: Wire Controller Field Props Correctly for UI Libraries
impact: HIGH
impactDescription: prevents form binding bugs and eliminates silent failures in 100% of UI library integrations
tags: ctrl, Controller, field-props, ui-libraries
---
## Wire Controller Field Props Correctly for UI Libraries
Different UI libraries expect different prop names. Map Controller's field props correctly: `onChange` sends data back, `onBlur` reports interaction, `value` sets the display, `ref` enables focus on error.
**Incorrect (spreading field on incompatible component):**
```typescript
function FormWithSelect({ control }: { control: Control<FormData> }) {
return (
<Controller
name="country"
control={control}
render={({ field }) => (
<Select {...field} /> // Select may not accept all field props directly
)}
/>
)
}
```
**Correct (manually wire required props):**
```typescript
function FormWithSelect({ control }: { control: Control<FormData> }) {
return (
<Controller
name="country"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange} // Map to component's change handler
onBlur={field.onBlur}
>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</Select>
)}
/>
)
}
```
**Common mappings by library:**
- MUI Select: `value`, `onChange` (receives event)
- Radix/shadcn Select: `value`, `onValueChange` (receives value directly)
- React Select: `value`, `onChange` (receives option object)
Reference: [useController](https://react-hook-form.com/docs/usecontroller)
references/ctrl-local-state-combination.md
---
title: Combine Local State with useController for UI-Only State
impact: MEDIUM
impactDescription: reduces form re-renders by 50%+ when UI state changes don't affect form data
tags: ctrl, useController, local-state, ui-state
---
## Combine Local State with useController for UI-Only State
It's valid to combine useController with local useState for UI-only state (like dropdown open/closed, formatting preview). Keep form data in useController and UI state separate.
**Incorrect (mixing UI state into form state):**
```typescript
function PhoneInput({ control }: { control: Control<FormData> }) {
const { field } = useController({
name: 'phone',
control,
defaultValue: { number: '', showFormatted: false }, // UI state in form
})
return (
<div>
<input
value={field.value.number}
onChange={(e) => field.onChange({ ...field.value, number: e.target.value })}
/>
<label>
<input
type="checkbox"
checked={field.value.showFormatted} // UI state pollutes form data
onChange={(e) => field.onChange({ ...field.value, showFormatted: e.target.checked })}
/>
Show formatted
</label>
</div>
)
}
```
**Correct (separate UI state from form state):**
```typescript
function PhoneInput({ control }: { control: Control<FormData> }) {
const { field } = useController({ name: 'phone', control })
const [showFormatted, setShowFormatted] = useState(false) // UI-only state
const displayValue = showFormatted ? formatPhone(field.value) : field.value
return (
<div>
<input
value={displayValue}
onChange={(e) => field.onChange(e.target.value)} // Only phone number in form
/>
<label>
<input
type="checkbox"
checked={showFormatted}
onChange={(e) => setShowFormatted(e.target.checked)} // Local state only
/>
Show formatted
</label>
</div>
)
}
```
Reference: [useController](https://react-hook-form.com/docs/usecontroller)
references/ctrl-single-usecontroller-per-component.md
---
title: Split Controlled Fields When Isolation Helps
impact: MEDIUM-HIGH
impactDescription: improves field ownership and re-render isolation where useful
tags: ctrl, useController, component-design, separation
---
## Split Controlled Fields When Isolation Helps
React Hook Form permits multiple `useController` calls in one component, and their returned objects do not collide when named clearly. Split fields into dedicated components when it improves ownership, reuse, or re-render isolation; do not enforce one hook per component mechanically.
**Incorrect:** Require exactly one `useController` call per component regardless of field coupling or component ownership.
**Correct:** Split controlled fields when isolation, reuse, or clearer ownership justifies the extra component boundary.
**Valid but coupled (both fields re-render together):**
```typescript
function DateRangeInput({ control }: { control: Control<FormData> }) {
const startField = useController({ name: 'startDate', control })
const endField = useController({ name: 'endDate', control })
return (
<div>
<DatePicker
value={startField.field.value}
onChange={startField.field.onChange}
error={startField.fieldState.error?.message}
/>
<DatePicker
value={endField.field.value}
onChange={endField.field.onChange}
error={endField.fieldState.error?.message}
/>
</div>
)
}
```
**Isolated reusable fields:**
```typescript
function DateRangeInput({ control }: { control: Control<FormData> }) {
return (
<div>
<DateInput control={control} name="startDate" label="Start Date" />
<DateInput control={control} name="endDate" label="End Date" />
</div>
)
}
function DateInput({ control, name, label }: DateInputProps) {
const { field, fieldState } = useController({ name, control })
return (
<DatePicker
label={label}
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)
}
```
Reference: [useController](https://react-hook-form.com/docs/usecontroller)
references/ctrl-usecontroller-isolation.md
---
title: Use useController for Re-render Isolation in Controlled Components
impact: HIGH
impactDescription: reduces re-renders from O(n) to O(1) per field change
tags: ctrl, useController, controlled-components, re-renders
---
## Use useController for Re-render Isolation in Controlled Components
useController creates a controlled input that only re-renders when its specific field value changes. This is essential for integrating with UI libraries like MUI, Ant Design, or custom components.
**Incorrect (inline Controller causes parent re-renders):**
```typescript
function PaymentForm() {
const { control, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="amount"
control={control}
render={({ field }) => (
<CurrencyInput {...field} /> // Parent re-renders affect this
)}
/>
<Controller
name="currency"
control={control}
render={({ field }) => (
<CurrencySelect {...field} />
)}
/>
</form>
)
}
```
**Correct (useController in dedicated component isolates re-renders):**
```typescript
function PaymentForm() {
const { control, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<AmountInput control={control} /> {/* Only re-renders on amount change */}
<CurrencySelectField control={control} /> {/* Only re-renders on currency change */}
</form>
)
}
function AmountInput({ control }: { control: Control<PaymentFormData> }) {
const { field } = useController({ name: 'amount', control })
return <CurrencyInput {...field} />
}
function CurrencySelectField({ control }: { control: Control<PaymentFormData> }) {
const { field } = useController({ name: 'currency', control })
return <CurrencySelect {...field} />
}
```
Reference: [useController](https://react-hook-form.com/docs/usecontroller)
references/formcfg-async-default-values.md
---
title: Use Async defaultValues for Server Data
impact: CRITICAL
impactDescription: eliminates manual useEffect reset patterns
tags: formcfg, async, default-values, data-fetching
---
## Use Async defaultValues for Server Data
React Hook Form supports async functions for `defaultValues`, eliminating the need for manual useEffect + reset() patterns when loading initial data from an API.
**Incorrect (manual useEffect reset pattern):**
```typescript
function EditUserForm({ userId }: { userId: string }) {
const { register, reset, handleSubmit, formState: { isLoading } } = useForm({
defaultValues: {
email: '',
name: '',
},
})
useEffect(() => {
async function loadUser() {
const user = await fetchUser(userId)
reset(user) // Manual reset required
}
loadUser()
}, [userId, reset])
if (isLoading) return <Spinner />
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('name')} />
</form>
)
}
```
**Correct (async defaultValues handles loading automatically):**
```typescript
function EditUserForm({ userId }: { userId: string }) {
const { register, handleSubmit, formState: { isLoading } } = useForm({
defaultValues: async () => {
const user = await fetchUser(userId)
return {
email: user.email,
name: user.name,
}
},
})
if (isLoading) return <Spinner />
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('name')} />
</form>
)
}
```
**Note:** defaultValues are cached after initial load. Use `reset()` with new values if you need to refresh data.
Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform)
references/formcfg-default-values.md
---
title: Always Provide defaultValues for Form Initialization
impact: CRITICAL
impactDescription: prevents undefined state bugs and enables reset() functionality
tags: formcfg, default-values, initialization, useForm
---
## Always Provide defaultValues for Form Initialization
Omitting `defaultValues` causes undefined state conflicts with controlled components and breaks `reset()` functionality. Always provide explicit defaults, using empty strings instead of undefined.
**Incorrect (no defaultValues, breaks reset and controlled components):**
```typescript
const { register, reset, handleSubmit } = useForm()
function ProfileForm({ user }: { user: User }) {
useEffect(() => {
reset(user) // reset() won't restore to "initial" state without defaultValues
}, [user, reset])
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} /> {/* undefined initial value */}
<input {...register('lastName')} />
</form>
)
}
```
**Correct (explicit defaultValues enable proper reset):**
```typescript
const { register, reset, handleSubmit } = useForm({
defaultValues: {
firstName: '',
lastName: '',
},
})
function ProfileForm({ user }: { user: User }) {
useEffect(() => {
reset(user) // reset() properly restores to defaultValues when called without args
}, [user, reset])
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} />
<input {...register('lastName')} />
</form>
)
}
```
**Note:** Avoid using custom objects with prototype methods (Moment, Luxon) as defaultValues. Use plain objects or primitives.
Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform)
references/formcfg-revalidate-mode.md
---
title: Choose Post-Submit Revalidation Deliberately
impact: CRITICAL
impactDescription: balances correction feedback against validation cost after a failed submit
tags: formcfg, revalidate-mode, re-renders, useForm
---
## Choose Post-Submit Revalidation Deliberately
After a failed submit, `reValidateMode` controls when fields with errors revalidate. The default `onChange` gives immediate confirmation as users correct errors. Choose `onBlur` when validation is expensive or errors should settle after field completion; this is a UX trade-off, not a universal performance rule.
**Incorrect:** Treat `onBlur` as universally faster and better after every failed submit.
**Correct:** Choose `onChange` or `onBlur` from correction UX and measured validation cost.
**Immediate correction feedback (default):**
```typescript
const { register, handleSubmit } = useForm({
mode: 'onSubmit',
reValidateMode: 'onChange', // Default: after first submit, validates on EVERY keystroke
})
function PaymentForm() {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('cardNumber', { required: true })} />
<input {...register('cvv', { required: true, maxLength: 4 })} />
</form>
)
}
```
**Deferred correction feedback:**
```typescript
const { register, handleSubmit } = useForm({
mode: 'onSubmit',
reValidateMode: 'onBlur', // After first submit, validates only on blur
})
function PaymentForm() {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('cardNumber', { required: true })} />
<input {...register('cvv', { required: true, maxLength: 4 })} />
</form>
)
}
```
Reference: [useForm - reValidateMode](https://react-hook-form.com/docs/useform)
references/formcfg-should-unregister.md
---
title: Choose shouldUnregister from Submission Semantics
impact: HIGH
impactDescription: reduces memory usage for forms with frequently mounted/unmounted fields
tags: formcfg, should-unregister, dynamic-forms, memory
---
## Choose shouldUnregister from Submission Semantics
By default, unmounted fields retain their values and remain in the submitted form data. Set `shouldUnregister: true` only when an unmounted input should behave like a native form control and disappear from submission. This changes data semantics; it is not merely a memory optimization.
**Incorrect (unmounted fields persist in memory):**
```typescript
const { register, handleSubmit } = useForm({
shouldUnregister: false, // Default: unmounted fields stay in form state
})
function MultiStepForm() {
const [step, setStep] = useState(1)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<input {...register('personalInfo.name')} />
)}
{step === 2 && (
<input {...register('companyInfo.company')} /> {/* Step 1 fields still in memory */}
)}
</form>
)
}
```
**Correct (unmounted fields cleaned up automatically):**
```typescript
const { register, handleSubmit } = useForm({
shouldUnregister: true, // Unmounted fields are omitted from submission
})
function MultiStepForm() {
const [step, setStep] = useState(1)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<input {...register('personalInfo.name')} />
)}
{step === 2 && (
<input {...register('companyInfo.company')} /> {/* Step 1 fields cleaned up */}
)}
</form>
)
}
```
**When NOT to use:**
- Multi-step wizards where you need to preserve values across steps
- Conditional fields that should retain values when hidden
With `useFieldArray`/`Controller`, avoid `shouldUnregister` when reorder or remount behavior would discard values unexpectedly. Test conditional-field, reset, and default-value flows.
Reference: [useForm - shouldUnregister](https://react-hook-form.com/docs/useform)
references/formcfg-useeffect-dependency.md
---
title: Avoid useForm Return Object in useEffect Dependencies
impact: CRITICAL
impactDescription: prevents infinite render loops
tags: formcfg, useEffect, dependencies, infinite-loop
---
## Avoid useForm Return Object in useEffect Dependencies
Adding the entire useForm return object to a useEffect dependency array causes infinite loops. Destructure only the specific methods you need.
**Incorrect (entire form object causes infinite loop):**
```typescript
function ContactForm({ defaultEmail }: { defaultEmail: string }) {
const form = useForm({
defaultValues: { email: '' },
})
useEffect(() => {
form.reset({ email: defaultEmail })
}, [form, defaultEmail]) // form reference changes on every render = infinite loop
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('email')} />
</form>
)
}
```
**Correct (destructure specific stable methods):**
```typescript
function ContactForm({ defaultEmail }: { defaultEmail: string }) {
const { register, handleSubmit, reset } = useForm({
defaultValues: { email: '' },
})
useEffect(() => {
reset({ email: defaultEmail })
}, [reset, defaultEmail]) // reset is stable, no infinite loop
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
</form>
)
}
```
**Note:** In a future major release, useForm return will be memoized. Until then, always destructure.
Reference: [useForm](https://react-hook-form.com/docs/useform)
references/formcfg-validation-mode.md
---
title: Choose Validation Mode from UX and Validation Cost
impact: CRITICAL
impactDescription: prevents re-renders on every keystroke
tags: formcfg, validation-mode, re-renders, useForm
---
## Choose Validation Mode from UX and Validation Cost
The `mode` option controls when React Hook Form validates before the first submit. `onSubmit` is the default and minimizes validation work, but `onBlur`, `onTouched`, or `onChange` may better match the product's feedback requirements. Do not label a supported mode incorrect without measuring an expensive form.
**Incorrect:** Ban `onChange` validation solely because it can re-render more often.
**Correct:** Select the validation mode from the required feedback timing and actual resolver cost.
**Live validation (use when the UX requires it):**
```typescript
const { register, handleSubmit, formState: { errors } } = useForm({
mode: 'onChange', // Triggers validation + re-render on EVERY input change
})
function RegistrationForm() {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true, pattern: /^\S+@\S+$/i })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
)
}
```
**Submit-time validation (default):**
```typescript
const { register, handleSubmit, formState: { errors } } = useForm({
mode: 'onSubmit', // Default: validates only when form is submitted
})
function RegistrationForm() {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true, pattern: /^\S+@\S+$/i })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
)
}
```
**When to use other modes:**
- `onBlur`: Validate when user leaves a field (good balance of UX and performance)
- `onTouched`: Like `onBlur` but only after first interaction
- `onChange`: Use for immediate feedback when resolver cost and error UX are acceptable
Reference: [useForm - mode](https://react-hook-form.com/docs/useform)
references/formstate-avoid-isvalid-with-onsubmit.md
---
title: Align Submit Gating with Validation Mode
impact: MEDIUM
impactDescription: avoids contradictory submit UX and unnecessary live validity subscriptions
tags: formstate, isValid, onSubmit, validation-mode, accessibility
---
## Align Submit Gating with Validation Mode
`formState.isValid` is derived from the form's validation result. In an `onSubmit` form, using it to disable the submit button before users can submit often conflicts with submit-time validation and can hide how to resolve errors. Subscribing to `formState.isValid` also makes React Hook Form run a full-form validation on mount and on every value change, even with `mode: 'onSubmit'` (v7.39.0+). With an expensive resolver that is real work you did not ask for.
**Incorrect (gate an `onSubmit` form on live validity):**
```tsx
const {
handleSubmit,
formState: { isValid },
} = useForm({ mode: 'onSubmit' })
return <button disabled={!isValid}>Submit</button>
```
**Correct (keep submit available and prevent duplicate submissions):**
For submit-time validation, keep the button available and prevent duplicate submissions with `isSubmitting`:
```tsx
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({ mode: 'onSubmit' })
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email ? <p>{errors.email.message}</p> : null}
<button disabled={isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit'}
</button>
</form>
)
```
If the product deliberately prevents submission until valid, choose a live validation mode and subscribe to `isValid` explicitly:
```tsx
const {
formState: { isValid, isSubmitting },
} = useForm({ mode: 'onChange' })
<button disabled={!isValid || isSubmitting}>Submit</button>
```
Ensure disabled-state styling, error messaging, and assistive text explain what remains invalid. Do not use a disabled submit button as the only error-discovery mechanism.
Reference: [React Hook Form formState](https://react-hook-form.com/docs/useform/formstate)
references/formstate-destructure-formstate.md
---
title: Destructure formState Properties Before Render
impact: MEDIUM
impactDescription: enables Proxy subscription optimization
tags: formstate, formState, destructure, proxy, subscription
---
## Destructure formState Properties Before Render
formState is wrapped in a Proxy that tracks which properties you access. Destructure the specific properties you need before render to enable the subscription optimization. Assigning the entire object disables it.
**Incorrect (entire object assignment disables Proxy):**
```typescript
function SubmitButton() {
const { handleSubmit, formState } = useForm()
return (
<button
disabled={!formState.isValid} // Proxy optimization disabled
onClick={handleSubmit(onSubmit)}
>
{formState.isSubmitting ? 'Saving...' : 'Save'}
</button>
)
}
```
**Correct (destructure enables selective subscription):**
```typescript
function SubmitButton() {
const { handleSubmit, formState: { isValid, isSubmitting } } = useForm()
return (
<button
disabled={!isValid} // Only subscribes to isValid changes
onClick={handleSubmit(onSubmit)}
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
)
}
```
**Note:** This also applies to useFormState hook - always destructure the properties you need.
Reference: [useFormState](https://react-hook-form.com/docs/useformstate)
references/formstate-getfieldstate-for-single-field.md
---
title: Use getFieldState and getErrors for Single-Field Reads
impact: MEDIUM
impactDescription: avoids whole-form subscriptions for single-field reads and re-renders for reads in handlers
tags: formstate, getFieldState, getErrors, single-field, subscription
---
## Use getFieldState and getErrors for Single-Field Reads
When you need a single field's state (dirty, touched, error), use `getFieldState()` instead of subscribing to whole-form state. When it is called with `formState` during render, it subscribes only to what it reads. Called without `formState`, or with `getErrors()` (7.86+) inside event handlers, it reads the current value with no subscription at all.
**Incorrect (useFormState creates subscription for one-time check):**
```typescript
function FieldHelpText({ control, name }: { control: Control; name: string }) {
const { touchedFields } = useFormState({ control }) // Subscribes to all touched changes
const wasTouched = touchedFields[name]
return wasTouched ? null : <span>Please fill out this field</span>
}
```
**Correct (getFieldState for the field you render, getErrors in handlers):**
```typescript
function SignupForm() {
const { register, getFieldState, getErrors, formState } = useForm<{ email: string }>()
// Render-time read: passing formState keeps it reactive, but it subscribes
// only to the state this call reads, not to every touched field
const { isTouched } = getFieldState('email', formState)
function handleEmailBlur() {
// Point-in-time read (7.86+): no validation, no subscription, no re-render
const error = getErrors('email')
if (error) analytics.track('email_invalid', { type: error.type })
}
return (
<form>
<input {...register('email', { required: 'Email is required', onBlur: handleEmailBlur })} />
{isTouched ? null : <span>Please fill out this field</span>}
</form>
)
}
```
**When to use each:**
- `useFormState`: a child component needs to re-render when form state changes
- `getFieldState(name, formState)`: render one field's state reactively
- `getFieldState(name)` or `getErrors(name)`: read the current value inside handlers or effects without re-rendering. `getErrors()` returns all errors, and `getErrors(['a', 'b'])` returns a tuple.
Reference: [useForm - getFieldState](https://react-hook-form.com/docs/useform/getfieldstate) · [useForm - getErrors](https://react-hook-form.com/docs/useform/geterrors)
references/formstate-subscribe-to-specific-fields.md
---
title: Subscribe to Specific Field Names in useFormState
impact: MEDIUM
impactDescription: reduces re-renders to only relevant field changes
tags: formstate, useFormState, name, specific-fields
---
## Subscribe to Specific Field Names in useFormState
useFormState accepts a `name` option to subscribe only to specific field state changes. Without it, the component re-renders on any field's state change.
**Incorrect (subscribes to all field state changes):**
```typescript
function PasswordStrengthIndicator({ control }: { control: Control }) {
const { errors, dirtyFields } = useFormState({ control }) // All fields
const passwordError = errors.password
const isPasswordDirty = dirtyFields.password
return isPasswordDirty && !passwordError ? (
<span>Password looks good!</span>
) : null
}
```
**Correct (subscribes only to password field):**
```typescript
function PasswordStrengthIndicator({ control }: { control: Control }) {
const { errors, dirtyFields } = useFormState({
control,
name: 'password', // Only re-renders on password state changes
})
const passwordError = errors.password
const isPasswordDirty = dirtyFields.password
return isPasswordDirty && !passwordError ? (
<span>Password looks good!</span>
) : null
}
```
**Multiple fields:**
```typescript
const { errors } = useFormState({
control,
name: ['email', 'password'], // Subscribe to multiple specific fields
})
```
Reference: [useFormState](https://react-hook-form.com/docs/useformstate)
references/formstate-useformstate-isolation.md
---
title: Use useFormState for Isolated State Subscriptions
impact: MEDIUM
impactDescription: prevents parent re-renders from state access in children
tags: formstate, useFormState, isolation, re-renders
---
## Use useFormState for Isolated State Subscriptions
useFormState allows subscribing to form state in child components without causing parent re-renders. Each useFormState instance is isolated and doesn't affect other subscribers.
**Incorrect (formState at root re-renders entire form):**
```typescript
function ContactForm() {
const { register, handleSubmit, formState: { errors, isDirty } } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
{errors.email && <span>Email required</span>} {/* Re-renders all on any state change */}
<input {...register('message')} />
<SaveIndicator isDirty={isDirty} /> {/* Prop drilling */}
</form>
)
}
```
**Correct (useFormState isolates subscriptions):**
```typescript
function ContactForm() {
const { register, handleSubmit, control } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<EmailField register={register} control={control} />
<input {...register('message')} />
<SaveIndicator control={control} /> {/* Isolated subscription */}
</form>
)
}
function EmailField({ register, control }: EmailFieldProps) {
const { errors } = useFormState({ control, name: 'email' })
return (
<div>
<input {...register('email', { required: true })} />
{errors.email && <span>Email required</span>}
</div>
)
}
// For error text alone, the built-in <ErrorMessage control={control} name="email" /> (7.88+)
// gives the same per-field isolation without a wrapper component (see valid-error-message-strategy)
function SaveIndicator({ control }: { control: Control }) {
const { isDirty } = useFormState({ control })
return isDirty ? <span>Unsaved changes</span> : null
}
```
Reference: [useFormState](https://react-hook-form.com/docs/useformstate)
references/integ-mui-controller-pattern.md
---
title: Use Controller for Material-UI Components
impact: MEDIUM
impactDescription: maintains controlled component behavior with proper event handling
tags: integ, mui, material-ui, Controller
---
## Use Controller for Material-UI Components
Material-UI components are controlled by design. Use Controller to wrap them, handling the onChange event object correctly (MUI passes the event, not the value directly).
**Incorrect (register doesn't work with MUI controlled components):**
```typescript
import { TextField } from '@mui/material'
function MuiForm() {
const { register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register('email')} // MUI TextField is controlled, register won't work
label="Email"
/>
</form>
)
}
```
**Correct (Controller handles MUI's event-based onChange):**
```typescript
import { TextField } from '@mui/material'
import { Controller } from 'react-hook-form'
function MuiForm() {
const { control, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field} // MUI TextField accepts onChange with event
label="Email"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</form>
)
}
```
Reference: [React Hook Form - UI Libraries](https://react-hook-form.com/get-started#IntegratingwithUIlibraries)
references/integ-shadcn-form-import.md
---
title: Verify shadcn Form Component Import Source
impact: MEDIUM
impactDescription: prevents silent component mismatch bugs
tags: integ, shadcn, imports, Form-component
---
## Verify shadcn Form Component Import Source
React Hook Form exports its own `<Form>` component. When using shadcn/ui, ensure you import the shadcn Form wrapper, not RHF's Form. Auto-imports often get this wrong.
**Incorrect (imports RHF Form instead of shadcn):**
```typescript
import { useForm, Form } from 'react-hook-form' // Wrong Form!
import { FormField, FormItem, FormLabel } from '@/components/ui/form'
function LoginForm() {
const form = useForm()
return (
<Form {...form}> {/* RHF Form doesn't work with shadcn FormField */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Input {...field} />
</FormItem>
)}
/>
</Form>
)
}
```
**Correct (separate imports for each library):**
```typescript
import { useForm } from 'react-hook-form'
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'
function LoginForm() {
const form = useForm()
return (
<Form {...form}> {/* shadcn Form wraps FormProvider correctly */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Input {...field} />
</FormItem>
)}
/>
</Form>
)
}
```
Reference: [shadcn Form](https://ui.shadcn.com/docs/components/form)
references/integ-shadcn-select-wiring.md
---
title: Wire shadcn Select with onValueChange Instead of Spread
impact: MEDIUM
impactDescription: prevents 100% of silent select binding failures with Radix-based components
tags: integ, shadcn, select, radix
---
## Wire shadcn Select with onValueChange Instead of Spread
shadcn's Select (built on Radix) uses `onValueChange` instead of `onChange`. Spreading field props directly doesn't work. Manually wire the value change handler.
**Incorrect (spread doesn't work with Radix Select):**
```typescript
function CountrySelect({ control }: { control: Control }) {
return (
<FormField
control={control}
name="country"
render={({ field }) => (
<Select {...field}> {/* field.onChange expects event, Radix passes value */}
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</SelectContent>
</Select>
)}
/>
)
}
```
**Correct (wire props individually):**
```typescript
function CountrySelect({ control }: { control: Control }) {
return (
<FormField
control={control}
name="country"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange} // Radix passes value directly
onOpenChange={() => field.onBlur()} // Trigger blur on close
>
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</SelectContent>
</Select>
)}
/>
)
}
```
Reference: [shadcn Select](https://ui.shadcn.com/docs/components/select)
references/integ-typed-submit-server-action.md
---
title: Submit with a Typed handleSubmit Result or Action
impact: MEDIUM-HIGH
impactDescription: typed submit results and direct Server Action wiring
tags: react-hook-form, submit, server-actions, integration, typescript
---
## Submit with a Typed handleSubmit Result or Action
Since RHF 7.84, `handleSubmit` returns the typed result of the `onValid` callback instead of discarding it, and the `action` option accepts a function (Server Action style) in addition to a URL string. Together they remove boilerplate between form validation and server mutation.
**Incorrect (throwing away the submit result):**
```tsx
const onSubmit = async (values: SignupValues) => {
const user = await createUser(values)
return user // result is swallowed by handleSubmit
}
// Handler must re-plumb state to reach the created user
<form onSubmit={form.handleSubmit(onSubmit)} />
```
**Correct (typed result flows through handleSubmit):**
```tsx
const onSubmit = async (values: SignupValues): Promise<User> => {
return createUser(values)
}
const submitted = form.handleSubmit(onSubmit)
const user: User = await submitted // typed as User
```
**Correct (function-based action for Server Actions):**
```tsx
const form = useForm<SignupValues>({
action: async (values) => {
'use server'
return createUser(values) // runs the same validation + submission path
},
})
// Progressive enhancement friendly: works even before hydration,
// like the classic URL-string action but with RHF's data contract
```
**Notes:**
- The function `action` receives validated values and runs alongside (not instead of) RHF's validation pipeline; URL-string `action` remains supported for native form posts.
- Pair with `useFormState`'s `isSubmitting`/`isSubmitSuccessful` for pending UI; Server Action results surface through the same state.
- v8 note: RHF v8 is in beta (breaking: `Controller`/custom register passes the input ref instead of a partial object) — stay on the 7.8x line for production until v8 is stable.
references/integ-value-transform.md
---
title: Transform Values at Controller Level for Type Coercion
impact: MEDIUM
impactDescription: prevents type coercion bugs in 100% of numeric/date form fields
tags: integ, transform, value-coercion, Controller
---
## Transform Values at Controller Level for Type Coercion
Native inputs return strings. When your form needs numbers, dates, or other types, transform values in the Controller render function rather than relying solely on `valueAsNumber` or `valueAsDate`.
**Incorrect (valueAsNumber has edge cases):**
```typescript
function QuantityInput() {
const { register } = useForm()
return (
<input
{...register('quantity', { valueAsNumber: true })} // Returns NaN for empty string
type="number"
/>
)
}
```
**Correct (explicit transformation in Controller):**
```typescript
function QuantityInput({ control }: { control: Control }) {
return (
<Controller
name="quantity"
control={control}
render={({ field }) => (
<input
type="number"
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value
field.onChange(value === '' ? null : parseInt(value, 10))
}}
onBlur={field.onBlur}
/>
)}
/>
)
}
```
**Alternative (Zod transform at schema level):**
```typescript
const schema = z.object({
quantity: z.string().transform((val) => (val === '' ? null : parseInt(val, 10))),
})
```
Reference: [React Hook Form - Advanced Usage](https://react-hook-form.com/advanced-usage)
references/sub-avoid-watch-in-render.md
---
title: Avoid Calling watch() in Render for One-Time Reads
impact: HIGH
impactDescription: prevents unnecessary subscriptions and re-renders
tags: sub, watch, getValues, render, one-time-read
---
## Avoid Calling watch() in Render for One-Time Reads
If you only need to read a value once (not subscribe to changes), use `getValues()` instead of `watch()`. Calling watch() creates a subscription that triggers re-renders on every change.
**Incorrect (watch creates subscription for one-time read):**
```typescript
function SubmitButton() {
const { watch, handleSubmit, formState: { isValid } } = useForm()
const handleClick = () => {
const email = watch('email') // Creates subscription, but we only need current value
analytics.track('form_submit_attempt', { email })
handleSubmit(onSubmit)()
}
return <button onClick={handleClick} disabled={!isValid}>Submit</button>
}
```
**Correct (getValues for one-time read):**
```typescript
function SubmitButton() {
const { getValues, handleSubmit, formState: { isValid } } = useForm()
const handleClick = () => {
const email = getValues('email') // No subscription, just current value
analytics.track('form_submit_attempt', { email })
handleSubmit(onSubmit)()
}
return <button onClick={handleClick} disabled={!isValid}>Submit</button>
}
```
**When to use each:**
- `watch()`: Need to react to value changes (display, conditional rendering)
- `getValues()`: Need current value at a point in time (event handlers, submit)
Reference: [useForm - getValues](https://react-hook-form.com/docs/useform/getvalues)
references/sub-deep-subscription.md
---
title: Subscribe Deep in Component Tree Where Data Is Needed
impact: CRITICAL
impactDescription: prevents parent re-renders from propagating to unrelated children
tags: sub, subscription, component-tree, re-renders
---
## Subscribe Deep in Component Tree Where Data Is Needed
Subscribe to form values as deep in the component tree as possible, where the data is actually used. This isolates re-renders to the specific component that needs the value.
**Incorrect (subscription at parent re-renders all children):**
```typescript
function CheckoutPage() {
const { control, register, handleSubmit } = useForm()
const paymentMethod = useWatch({ control, name: 'paymentMethod' }) // Parent subscribes
return (
<form onSubmit={handleSubmit(onSubmit)}>
<ShippingSection register={register} /> {/* Re-renders on paymentMethod change */}
<BillingSection register={register} /> {/* Re-renders on paymentMethod change */}
<PaymentSection
register={register}
paymentMethod={paymentMethod} {/* Prop drilling */}
/>
</form>
)
}
```
**Correct (subscription at leaf component isolates re-renders):**
```typescript
function CheckoutPage() {
const { control, register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<ShippingSection register={register} /> {/* Never re-renders for payment */}
<BillingSection register={register} /> {/* Never re-renders for payment */}
<PaymentSection register={register} control={control} />
</form>
)
}
function PaymentSection({ register, control }: PaymentSectionProps) {
const paymentMethod = useWatch({ control, name: 'paymentMethod' }) // Only this re-renders
return (
<div>
<select {...register('paymentMethod')}>
<option value="card">Credit Card</option>
<option value="paypal">PayPal</option>
</select>
{paymentMethod === 'card' && <CardFields register={register} />}
</div>
)
}
```
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)
references/sub-useformcontext-sparingly.md
---
title: Use useFormContext Sparingly for Deep Nesting
impact: MEDIUM
impactDescription: reduces prop drilling but increases implicit dependencies
tags: sub, useFormContext, FormProvider, prop-drilling
---
## Use useFormContext Sparingly for Deep Nesting
useFormContext eliminates prop drilling by accessing form methods via context, but creates implicit dependencies that are harder to track. Use it for deeply nested components; prefer explicit props for shallow nesting.
**Incorrect (useFormContext for shallow nesting):**
```typescript
function ContactForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NameInput /> {/* One level deep, context overhead not needed */}
<EmailInput />
</form>
</FormProvider>
)
}
function NameInput() {
const { register } = useFormContext() // Implicit dependency
return <input {...register('name')} />
}
```
**Correct (explicit props for shallow nesting):**
```typescript
function ContactForm() {
const { register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<NameInput register={register} /> {/* Explicit dependency */}
<EmailInput register={register} />
</form>
)
}
function NameInput({ register }: { register: UseFormRegister<ContactFormData> }) {
return <input {...register('name')} />
}
```
**When to use useFormContext:**
- Components nested 3+ levels deep
- Shared components used across multiple forms
- Complex form sections with many fields
Reference: [useFormContext](https://react-hook-form.com/docs/useformcontext)
references/sub-usewatch-default-value.md
---
title: Provide defaultValue to useWatch for Initial Render
impact: MEDIUM-HIGH
impactDescription: prevents undefined flash on initial render
tags: sub, useWatch, default-value, hydration
---
## Provide defaultValue to useWatch for Initial Render
On the initial render, `useWatch` returns the current form value when one is already registered; `defaultValue` (or `useForm({ defaultValues })`) is the fallback before the form has mounted. Provide one source of defaults consistently so controlled UI does not begin as `undefined`.
**Incorrect (undefined on first render):**
```typescript
function PriceDisplay({ control }: { control: Control<OrderForm> }) {
const quantity = useWatch({ control, name: 'quantity' })
return (
<div>
{quantity !== undefined ? ( // Undefined check required
<span>Quantity: {quantity}</span>
) : (
<span>Loading...</span> // Flash of loading state
)}
</div>
)
}
```
**Correct (defaultValue prevents undefined):**
```typescript
function PriceDisplay({ control }: { control: Control<OrderForm> }) {
const quantity = useWatch({
control,
name: 'quantity',
defaultValue: 1, // Immediate value, no undefined check needed
})
return (
<div>
<span>Quantity: {quantity}</span>
</div>
)
}
```
**Note:** defaultValue should match the type expected by your form schema to maintain type safety.
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)
references/sub-usewatch-over-watch.md
---
title: Use useWatch Instead of watch for Isolated Re-renders
impact: CRITICAL
impactDescription: reduces re-renders by 10-50× in complex forms with multiple watchers
tags: sub, useWatch, watch, re-renders, subscription
---
## Use useWatch Instead of watch for Isolated Re-renders
The `watch()` method triggers re-renders at the useForm hook level, affecting the entire form component. Use `useWatch()` in child components to isolate re-renders to only the components that need the watched value.
**Incorrect (watch at root causes entire form to re-render):**
```typescript
function CheckoutForm() {
const { register, watch, handleSubmit } = useForm()
const shippingMethod = watch('shippingMethod') // Every change re-renders entire form
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('shippingMethod')}>
<option value="standard">Standard</option>
<option value="express">Express</option>
</select>
<ShippingCost method={shippingMethod} />
<input {...register('address')} />
<input {...register('city')} />
</form>
)
}
```
**Correct (useWatch isolates re-render to child component):**
```typescript
function CheckoutForm() {
const { register, handleSubmit, control } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('shippingMethod')}>
<option value="standard">Standard</option>
<option value="express">Express</option>
</select>
<ShippingCostDisplay control={control} /> {/* Only this re-renders */}
<input {...register('address')} />
<input {...register('city')} />
</form>
)
}
function ShippingCostDisplay({ control }: { control: Control<CheckoutFormData> }) {
const shippingMethod = useWatch({ control, name: 'shippingMethod' })
return <ShippingCost method={shippingMethod} />
}
```
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)
references/sub-usewatch-with-getvalues.md
---
title: Combine useWatch with getValues for Timing Safety
impact: HIGH
impactDescription: prevents missed updates due to subscription timing
tags: sub, useWatch, getValues, timing, subscription
---
## Combine useWatch with getValues for Timing Safety
If `setValue()` is called before useWatch establishes its subscription, the update is missed. Combine useWatch with getValues to guarantee no updates are lost.
**Incorrect (setValue before subscription misses update):**
```typescript
function PrefillableForm() {
const { setValue, control } = useForm()
const couponCode = useWatch({ control, name: 'couponCode' })
useEffect(() => {
const savedCoupon = localStorage.getItem('savedCoupon')
if (savedCoupon) {
setValue('couponCode', savedCoupon) // May fire before useWatch subscription
}
}, [setValue])
return <div>Applied coupon: {couponCode}</div> {/* May show stale value */}
}
```
**Correct (merge subscription with current values):**
```typescript
function PrefillableForm() {
const { setValue, control, getValues } = useForm()
const useFormValues = () => ({
...useWatch({ control }),
...getValues(), // Fallback ensures no missed values
})
const { couponCode } = useFormValues()
useEffect(() => {
const savedCoupon = localStorage.getItem('savedCoupon')
if (savedCoupon) {
setValue('couponCode', savedCoupon)
}
}, [setValue])
return <div>Applied coupon: {couponCode}</div> {/* Always shows current value */}
}
```
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)
references/sub-watch-specific-fields.md
---
title: Watch Specific Fields Instead of Entire Form
impact: CRITICAL
impactDescription: reduces re-renders from N fields to 1 field change
tags: sub, watch, specific-fields, re-renders
---
## Watch Specific Fields Instead of Entire Form
Calling `watch()` without arguments subscribes to ALL form fields, causing re-renders on any field change. Always specify the field names you need.
**Incorrect (watches all fields, re-renders on any change):**
```typescript
function OrderForm() {
const { register, watch, handleSubmit } = useForm()
const formValues = watch() // Re-renders when ANY field changes
const total = calculateTotal(formValues.quantity, formValues.price)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customerName')} /> {/* Changes here trigger total recalc */}
<input {...register('email')} /> {/* Changes here trigger total recalc */}
<input {...register('quantity', { valueAsNumber: true })} />
<input {...register('price', { valueAsNumber: true })} />
<div>Total: ${total}</div>
</form>
)
}
```
**Correct (watches only needed fields):**
```typescript
function OrderForm() {
const { register, watch, handleSubmit } = useForm()
const [quantity, price] = watch(['quantity', 'price']) // Only re-renders when these change
const total = calculateTotal(quantity, price)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customerName')} /> {/* No re-render on change */}
<input {...register('email')} /> {/* No re-render on change */}
<input {...register('quantity', { valueAsNumber: true })} />
<input {...register('price', { valueAsNumber: true })} />
<div>Total: ${total}</div>
</form>
)
}
```
Reference: [useForm - watch](https://react-hook-form.com/docs/useform/watch)
references/valid-delay-error.md
---
title: Use delayError to Debounce Rapid Error Display
impact: MEDIUM
impactDescription: reduces UI flicker during fast typing validation
tags: valid, delayError, debounce, user-experience
---
## Use delayError to Debounce Rapid Error Display
When using `onChange` mode, errors appear and disappear rapidly as users type. Use `delayError` to add a small delay, preventing UI flicker while still providing timely feedback.
**Incorrect (errors flash rapidly during typing):**
```typescript
function SearchForm() {
const { register, formState: { errors } } = useForm({
mode: 'onChange',
})
return (
<form>
<input {...register('query', { minLength: 3 })} />
{errors.query && <span>Min 3 characters</span>} {/* Flashes on/off rapidly */}
</form>
)
}
```
**Correct (error display debounced):**
```typescript
function SearchForm() {
const { register, formState: { errors } } = useForm({
mode: 'onChange',
delayError: 300, // 300ms delay before showing errors
})
return (
<form>
<input {...register('query', { minLength: 3 })} />
{errors.query && <span>Min 3 characters</span>} {/* Appears after 300ms delay */}
</form>
)
}
```
**When to use:**
- Real-time validation with `onChange` mode
- Fields with character count requirements
- Search inputs with minimum length
Reference: [useForm - delayError](https://react-hook-form.com/docs/useform)
references/valid-dynamic-schema-factory.md
---
title: Use Schema Factory for Dynamic Validation
impact: HIGH
impactDescription: enables context-dependent validation without render-time schema creation
tags: valid, schema, factory, dynamic, conditional
---
## Use Schema Factory for Dynamic Validation
When validation rules depend on runtime context (user role, feature flags), use a factory function to create schemas. This keeps schema creation outside the render cycle while allowing dynamic rules.
**Incorrect (schema recreated in component based on props):**
```typescript
function OrderForm({ maxQuantity }: { maxQuantity: number }) {
const { register, handleSubmit } = useForm({
resolver: zodResolver(
z.object({
quantity: z.number().max(maxQuantity), // Recreated when maxQuantity changes
notes: z.string().optional(),
})
),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('quantity', { valueAsNumber: true })} />
</form>
)
}
```
**Correct (factory function creates schema outside render):**
```typescript
const createOrderSchema = (maxQuantity: number) =>
z.object({
quantity: z.number().max(maxQuantity, `Maximum ${maxQuantity} items`),
notes: z.string().optional(),
})
function OrderForm({ maxQuantity }: { maxQuantity: number }) {
const schema = useMemo(() => createOrderSchema(maxQuantity), [maxQuantity])
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('quantity', { valueAsNumber: true })} />
</form>
)
}
```
Reference: [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)
references/valid-error-message-strategy.md
---
title: Render Field Errors with ErrorMessage or Optional Chaining
impact: MEDIUM-HIGH
impactDescription: prevents runtime errors from undefined nested properties
tags: valid, errors, optional-chaining, nested-fields
---
## Render Field Errors with ErrorMessage or Optional Chaining
Error objects can have deeply nested paths for nested fields. Since 7.88, React Hook Form ships an `<ErrorMessage>` component that looks up the path for you and re-renders only when that field's error changes. Otherwise use optional chaining to read errors safely.
**Incorrect (direct access throws on undefined):**
```typescript
function AddressForm() {
const { register, formState: { errors } } = useForm()
return (
<form>
<input {...register('address.street', { required: true })} />
<span>{errors.address.street.message}</span> {/* Throws if address undefined */}
<input {...register('address.city', { required: true })} />
<span>{errors.address.city.message}</span> {/* Throws if address undefined */}
</form>
)
}
```
**Correct (optional chaining for safe access):**
```typescript
function AddressForm() {
const { register, formState: { errors } } = useForm()
return (
<form>
<input {...register('address.street', { required: true })} />
<span>{errors.address?.street?.message}</span> {/* Safe access */}
<input {...register('address.city', { required: true })} />
<span>{errors.address?.city?.message}</span> {/* Safe access */}
</form>
)
}
```
**Correct (built-in ErrorMessage, 7.88+):**
```typescript
import { useForm, ErrorMessage } from 'react-hook-form'
type Address = { address: { street: string; city: string } }
function AddressForm() {
const { register, control } = useForm<Address>()
return (
<form>
<input
{...register('address.street', { required: 'Street is required' })}
aria-describedby="street-error"
/>
{/* Subscribes to this field's error only; renders nothing when there is none */}
<ErrorMessage
control={control}
name="address.street"
render={({ message }) => <p id="street-error" role="alert">{message}</p>}
/>
</form>
)
}
```
Notes on the built-in component:
- It reads `control` or the nearest `FormProvider`. Without either, it throws.
- It has no `errors` prop and does not forward extra props through `as`. Set `id`, `role` or `className` inside `render`.
- `render` also receives `messages` (all errors for the field) when the form uses `criteriaMode: 'all'`.
- `name` also accepts `'root'` and `'root.serverError'` for form-level errors.
- The older `@hookform/error-message` package still works, but the built-in component needs no extra dependency. For a lookup by path in plain code, `react-hook-form` exports `get`, so lodash is not needed.
Reference: [React Hook Form - ErrorMessage](https://react-hook-form.com/docs/useformstate/errormessage) · [Advanced Usage](https://react-hook-form.com/advanced-usage)
references/valid-form-level-validate.md
---
title: Validate Cross-Fields with the Form-Level validate Option
impact: MEDIUM-HIGH
impactDescription: cross-field rules without a schema library or wrapper components
tags: react-hook-form, validation, cross-field, useForm
---
## Validate Cross-Fields with the Form-Level validate Option
Since RHF 7.72, `useForm` accepts a built-in form-level `validate` function. Use it for cross-field rules (password confirmation, date ranges, dependent constraints) that previously required a schema resolver or watch-driven manual validation.
**Incorrect (watch + manual error injection for cross-field rules):**
```tsx
const password = watch('password')
useEffect(() => {
if (confirmPassword && password !== confirmPassword) {
setError('confirmPassword', { message: 'Passwords do not match' })
}
}, [password, confirmPassword])
// Effect churn, stale errors after edits, easy to forget cleanup
```
**Correct (form-level validate):**
```tsx
const form = useForm<SignupValues>({
validate: (values) => {
if (values.password !== values.confirmPassword) {
return { confirmPassword: 'Passwords do not match' }
}
if (values.endDate <= values.startDate) {
return { endDate: 'End date must be after start date' }
}
return undefined
},
})
// Return field-keyed errors (or a root error string) from one place;
// RHF runs it with the rest of validation and merges the results
```
**Notes:**
- The function receives all current values and returns an errors object keyed by field path, a root error string, or `undefined` when valid.
- It composes with resolvers and inline rules; keep single-field rules in schemas and reserve `validate` for dependencies between fields.
- Also since 7.72, `formState` subscribes to submit state, so `isSubmitted`/`isSubmitting` drive UI without custom bookkeeping.
references/valid-inline-vs-resolver.md
---
title: Prefer Resolver Over Inline Validation for Complex Rules
impact: HIGH
impactDescription: centralizes validation logic and enables type inference
tags: valid, resolver, inline-validation, schema
---
## Prefer Resolver Over Inline Validation for Complex Rules
Inline validation rules in `register()` are convenient for simple cases, but resolvers (Zod, Yup) provide better type safety, centralized logic, and cross-field validation capabilities.
**Incorrect (complex inline validation scattered across inputs):**
```typescript
function CheckoutForm() {
const { register, handleSubmit, watch } = useForm()
const billingAddressSame = watch('billingAddressSame')
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', {
required: 'Email required',
pattern: { value: /^\S+@\S+$/i, message: 'Invalid email' },
})} />
<input {...register('billingAddressSame')} type="checkbox" />
<input {...register('billingStreet', {
required: !billingAddressSame && 'Street required', // Cross-field logic inline
})} />
</form>
)
}
```
**Correct (resolver centralizes all validation):**
```typescript
const checkoutSchema = z.object({
email: z.email('Invalid email'),
billingAddressSame: z.boolean(),
billingStreet: z.string().optional(),
}).refine(
(data) => data.billingAddressSame || data.billingStreet,
{ error: 'Street required', path: ['billingStreet'] }
)
type CheckoutFormData = z.infer<typeof checkoutSchema>
function CheckoutForm() {
const { register, handleSubmit } = useForm<CheckoutFormData>({
resolver: zodResolver(checkoutSchema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('billingAddressSame')} type="checkbox" />
<input {...register('billingStreet')} />
</form>
)
}
```
Reference: [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)
references/valid-native-validation.md
---
title: Consider Native Validation for Simple Forms
impact: MEDIUM
impactDescription: reduces JavaScript validation overhead for basic constraints
tags: valid, native-validation, browser, performance
---
## Consider Native Validation for Simple Forms
For simple forms with basic constraints (required, minLength, pattern), browser-native validation eliminates JavaScript validation overhead. Enable with `shouldUseNativeValidation`.
**Incorrect (JavaScript validates simple constraints):**
```typescript
function NewsletterForm() {
const { register, handleSubmit, formState: { errors } } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: 'Email required',
pattern: { value: /^\S+@\S+$/i, message: 'Invalid email' },
})}
type="email"
/>
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Subscribe</button>
</form>
)
}
```
**Correct (browser handles validation natively):**
```typescript
function NewsletterForm() {
const { register, handleSubmit } = useForm({
shouldUseNativeValidation: true,
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', { required: true })}
type="email" // Browser validates email format
required // Browser shows native required message
/>
<button type="submit">Subscribe</button>
</form>
)
}
```
**When NOT to use:**
- Custom error message styling required
- Complex cross-field validation
- Need consistent UX across browsers
Reference: [useForm - shouldUseNativeValidation](https://react-hook-form.com/docs/useform)
references/valid-resolver-caching.md
---
title: Define Schema Outside Component for Resolver Caching
impact: HIGH
impactDescription: prevents schema recreation on every render
tags: valid, resolver, schema, caching, zod
---
## Define Schema Outside Component for Resolver Caching
Define validation schemas outside the component to enable resolver caching. Schemas defined inside components are recreated on every render, bypassing optimization.
**Incorrect (schema recreated on every render):**
```typescript
function RegistrationForm() {
const schema = z.object({ // Created fresh on every render
email: z.email(),
password: z.string().min(8),
})
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema), // New resolver instance each render
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('password')} type="password" />
</form>
)
}
```
**Correct (schema defined once, resolver cached):**
```typescript
const registrationSchema = z.object({ // Created once at module load
email: z.email(),
password: z.string().min(8),
})
type RegistrationFormData = z.infer<typeof registrationSchema>
function RegistrationForm() {
const { register, handleSubmit } = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema), // Stable resolver reference
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('password')} type="password" />
</form>
)
}
```
Reference: [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)
references/valid-trigger-should-touch.md
---
title: Mark Fields Touched When Triggering Validation
impact: MEDIUM
impactDescription: trigger() alone validates without touching, so errors gated on isTouched stay hidden; shouldTouch (7.87+) fixes that without setValue workarounds
tags: valid, trigger, touched, wizard, multi-step
---
## Mark Fields Touched When Triggering Validation
Multi-step forms call `trigger()` before moving to the next step. By default, `trigger()` validates but leaves the fields untouched. Any UI that only shows errors once a field `isTouched` then hides the errors that are blocking the user. Since 7.87, `trigger(names, { shouldTouch: true })` marks the fields as touched, whatever the validation result.
**Incorrect (errors stay hidden, or touched state is faked with setValue):**
```typescript
async function goToNextStep() {
const valid = await trigger(['firstName', 'lastName'])
// Untouched fields: errors gated on isTouched are not shown
if (valid) setStep(2)
}
// Workaround: rewrites values just to flip touched state
setValue('firstName', getValues('firstName'), { shouldTouch: true, shouldValidate: true })
```
**Correct (validate and touch in one call):**
```typescript
const { register, trigger } = useForm<{ firstName: string; lastName: string; plan: string }>()
async function goToNextStep() {
const valid = await trigger(['firstName', 'lastName'], { shouldTouch: true })
if (valid) setStep(2)
}
// Validate and touch every registered field, e.g. on a final review step:
await trigger(undefined, { shouldTouch: true })
```
`shouldFocus: true` can be combined with it to move focus to the first invalid field.
**When NOT to use this pattern:**
- Validating in the background, for example to enable a button, where the user has not acted on those fields yet: marking them touched would show errors too early
Reference: [useForm - trigger](https://react-hook-form.com/docs/useform/trigger) · [v7.87.0 release](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.87.0)
SKILL.md
---
name: react-hook-form
description: React Hook Form 7 forms and validation.
---
# React Hook Form Best Practices
Comprehensive performance optimization guide for React Hook Form applications. Contains 45 rules across 8 categories, prioritized by impact to guide form development, automated refactoring, and code generation.
## When to Apply
Reference these guidelines when:
- Writing new forms with React Hook Form
- Configuring useForm options (mode, defaultValues, validation)
- Subscribing to form values with watch/useWatch
- Integrating controlled UI components (MUI, shadcn, Ant Design)
- Managing dynamic field arrays with useFieldArray
- Reviewing forms for performance issues
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Form Configuration | CRITICAL | `formcfg-` |
| 2 | Field Subscription | CRITICAL | `sub-` |
| 3 | Controlled Components | HIGH | `ctrl-` |
| 4 | Validation Patterns | HIGH | `valid-` |
| 5 | Field Arrays | MEDIUM-HIGH | `array-` |
| 6 | State Management | MEDIUM | `formstate-` |
| 7 | Integration Patterns | MEDIUM | `integ-` |
| 8 | Advanced Patterns | LOW | `adv-` |
## Quick Reference
### 1. Form Configuration (CRITICAL)
- `formcfg-validation-mode` - Choose validation mode from UX and validation cost
- `formcfg-revalidate-mode` - Choose post-submit revalidation deliberately
- `formcfg-default-values` - Always provide defaultValues for form initialization
- `formcfg-async-default-values` - Use async defaultValues for server data
- `formcfg-should-unregister` - Choose shouldUnregister from submission semantics
- `formcfg-useeffect-dependency` - Avoid useForm return object in useEffect dependencies
### 2. Field Subscription (CRITICAL)
- `sub-usewatch-over-watch` - Use useWatch instead of watch for isolated re-renders
- `sub-watch-specific-fields` - Watch specific fields instead of entire form
- `sub-usewatch-with-getvalues` - Combine useWatch with getValues for timing safety
- `sub-deep-subscription` - Subscribe deep in component tree where data is needed
- `sub-avoid-watch-in-render` - Avoid calling watch() in render for one-time reads
- `sub-usewatch-default-value` - Provide defaultValue to useWatch for initial render
- `sub-useformcontext-sparingly` - Use useFormContext sparingly for deep nesting
### 3. Controlled Components (HIGH)
- `ctrl-usecontroller-isolation` - Use useController for re-render isolation
- `ctrl-avoid-double-registration` - Avoid double registration with useController
- `ctrl-controller-field-props` - Wire Controller field props correctly for UI libraries
- `ctrl-single-usecontroller-per-component` - Split controlled fields when isolation helps
- `ctrl-local-state-combination` - Combine local state with useController for UI-only state
### 4. Validation Patterns (HIGH)
- `valid-resolver-caching` - Define schema outside component for resolver caching
- `valid-dynamic-schema-factory` - Use schema factory for dynamic validation
- `valid-error-message-strategy` - Render field errors with ErrorMessage (7.88+) or optional chaining
- `valid-trigger-should-touch` - Mark fields touched when triggering validation (7.87+)
- `valid-inline-vs-resolver` - Prefer resolver over inline validation for complex rules
- `valid-delay-error` - Use delayError to debounce rapid error display
- `valid-native-validation` - Consider native validation for simple forms
- `valid-form-level-validate` - Validate cross-fields with the form-level validate option (7.72+)
### 5. Field Arrays (MEDIUM-HIGH)
- `array-use-field-id-as-key` - Use field.id as key in useFieldArray maps
- `array-complete-default-objects` - Provide complete default objects for field array operations
- `array-separate-crud-operations` - Separate sequential field array operations
- `array-unique-fieldarray-per-name` - Use single useFieldArray instance per field name
- `array-virtualization-formprovider` - Use FormProvider for virtualized field arrays
### 6. State Management (MEDIUM)
- `formstate-destructure-formstate` - Destructure formState properties before render
- `formstate-useformstate-isolation` - Use useFormState for isolated state subscriptions
- `formstate-getfieldstate-for-single-field` - Use getFieldState and getErrors for single-field reads
- `formstate-subscribe-to-specific-fields` - Subscribe to specific field names in useFormState
- `formstate-avoid-isvalid-with-onsubmit` - Align submit gating with validation mode
### 7. Integration Patterns (MEDIUM)
- `integ-shadcn-form-import` - Verify shadcn Form component import source
- `integ-shadcn-select-wiring` - Wire shadcn Select with onValueChange instead of spread
- `integ-mui-controller-pattern` - Use Controller for Material-UI components
- `integ-value-transform` - Transform values at Controller level for type coercion
- `integ-typed-submit-server-action` - Submit with a typed handleSubmit result or Server Action (7.84+)
### 8. Advanced Patterns (LOW)
- `adv-formprovider-memo` - Memoize FormProvider children only after profiling
- `adv-devtools-performance` - Disable DevTools in production and during performance testing
- `adv-testing-wrapper` - Create test wrapper with QueryClient and AuthProvider
- `adv-opaque-types` - Register value classes in OpaqueTypes (7.87+)
## How to Use
Run in the project root first:
```bash
npx -y @pymodel/react-frontend-skills detect # installed vs. targeted versions
npx -y @pymodel/react-frontend-skills scan --skill react-hook-form # deprecated APIs → rule IDs
```
Read `references/<rule-id>.md` for each rule above that matches the task and for each `scan` hit: why it matters, then incorrect vs. correct code.
## Related Skills
- For schema validation with Zod resolver, see `zod` skill
- For React 19 server actions, see `react` skill
- For UI/UX form design, see `ui-design` skill