
React Hook Form
- 2.2k installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
react-hook-form is a performance rulebook for client-side React Hook Form validation and subscription patterns.
About
The react-hook-form skill from dot-skills is a 45-rule performance guide across eight categories for client-side controlled forms. Critical areas: form configuration (onSubmit mode, defaultValues, shouldUnregister), field subscription (watch vs useWatch vs subscribe API v7.55+), controlled component integration (MUI, shadcn, Ant Design), validation patterns, useFieldArray dynamic fields, submit lifecycle, and advanced patterns. Explicitly excludes React 19 Server Actions and useActionState (use react-19 skill). Not for TanStack Form nested schema cases or trivial uncontrolled FormData forms. Categories prefixed formcfg-, sub-, ctrl-, valid-, array-, formstate-, integ-, adv- by impact. Use when writing new RHF forms, reviewing performance issues, or integrating UI libraries with useController.
- 45 prioritized rules across form config, subscription, validation, and arrays.
- onSubmit validation mode and defaultValues initialization best practices.
- useWatch and subscribe() API for granular re-render control.
- Controlled UI integration patterns for MUI and shadcn.
- Explicitly not for React 19 Server Actions (use react-19 skill).
React Hook Form by the numbers
- 2,176 all-time installs (skills.sh)
- +382 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #214 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
react-hook-form capabilities & compatibility
- Capabilities
- useform configuration rules · field subscription optimization · controlled component integration · validation pattern guidance · usefieldarray dynamic forms
- Use cases
- frontend · testing
npx skills add https://github.com/pproenca/dot-skills --skill react-hook-formAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.2k |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I build performant React Hook Form apps without unnecessary re-renders?
Optimize client-side React Hook Form apps with useForm config, subscriptions, controlled components, validation, and field arrays.
Who is it for?
React developers building or auditing client-side forms with React Hook Form.
Skip if: React 19 Server Actions forms (use react-19 skill) or trivial single-field forms.
When should I use this skill?
User mentions React Hook Form, useWatch, useFieldArray, or RHF performance tuning.
What you get
Optimized useForm configuration, subscriptions, and controlled integrations following 45 prioritized rules.
- Optimized form components
- Subscription-safe field hooks
- Async submit handlers
By the numbers
- Contains 45 rules across 8 categories
- Version 1.2.0 released May 2026
- Covers React Hook Form v7.55+ subscribe() API
Files
React Hook Form Best Practices by Community
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 / subscribe
- Integrating controlled UI components (MUI, shadcn, Ant Design)
- Managing dynamic field arrays with useFieldArray
- Handling async submit, server errors, and submit lifecycle state
- Reviewing forms for performance issues
When NOT to Use This Skill
- React 19 Server Actions / `useActionState` — use the
react-19skill instead - Deeply nested, fully type-safe forms — TanStack Form may be a better fit for forms with complex nested schemas; this skill assumes you've already chosen RHF
- Single-input or trivial forms — uncontrolled
<form>+FormDatais often simpler than pulling in any library
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- Use onSubmit mode for optimal performanceformcfg-revalidate-mode- Consider reValidateMode for expensive validationformcfg-default-values- Always provide defaultValues for form initializationformcfg-async-default-values- Use async defaultValues for server dataformcfg-should-unregister- Enable shouldUnregister for dynamic form memory efficiencyformcfg-useeffect-dependency- Avoid useForm return object in useEffect dependenciesformcfg-disabled-prop- Understand that register's disabled prop clears the value
2. Field Subscription (CRITICAL)
sub-usewatch-over-watch- Use useWatch instead of watch for isolated re-renderssub-watch-specific-fields- Watch specific fields instead of entire formsub-subscribe-outside-react- Use subscribe() for non-UI side-effects (analytics, autosave)sub-usewatch-with-getvalues- Combine useWatch with getValues for timing safetysub-deep-subscription- Subscribe deep in component tree where data is neededsub-avoid-watch-in-render- Avoid calling watch() in render for one-time readssub-usewatch-default-value- Provide defaultValue to useWatch for initial rendersub-useformcontext-sparingly- Use useFormContext sparingly for deep nesting
3. Controlled Components (HIGH)
ctrl-usecontroller-isolation- Isolate controlled inputs in dedicated child componentsctrl-avoid-double-registration- Avoid double registration with useControllerctrl-controller-field-props- Wire Controller field props correctly for UI librariesctrl-single-usecontroller-per-component- Use single useController per componentctrl-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 cachingvalid-server-errors- Surface server errors via setError('root.serverError', ...)valid-dynamic-schema-factory- Use schema factory for dynamic validationvalid-error-message-strategy- Access errors via optional chaining or lodash getvalid-inline-vs-resolver- Prefer resolver over inline validation for complex rulesvalid-delay-error- Use delayError to debounce rapid error displayvalid-native-validation- Consider native validation for simple forms
5. Field Arrays (MEDIUM-HIGH)
array-use-field-id-as-key- Use field.id as key in useFieldArray mapsarray-complete-default-objects- Provide complete default objects for field array operationsarray-separate-crud-operations- Separate sequential field array operationsarray-unique-fieldarray-per-name- Use single useFieldArray instance per field namearray-virtualization-formprovider- Use FormProvider for virtualized field arrays
6. State Management (MEDIUM)
formstate-async-submit-lifecycle- Wrap async submit handlers in try/catch and reset on isSubmitSuccessfulformstate-destructure-formstate- Destructure formState properties before renderformstate-useformstate-isolation- Use useFormState for isolated state subscriptionsformstate-getfieldstate-for-single-field- Use getFieldState for single field state accessformstate-subscribe-to-specific-fields- Subscribe to specific field names in useFormStateformstate-avoid-isvalid-with-onsubmit- Avoid isValid with onSubmit mode for button state
7. Integration Patterns (MEDIUM)
integ-shadcn-form-import- Verify shadcn Form component import sourceinteg-shadcn-select-wiring- Wire shadcn Select with onValueChange instead of spreadinteg-mui-controller-pattern- Use Controller for Material-UI componentsinteg-value-transform- Transform values at Controller level for type coercion
8. Advanced Patterns (LOW)
adv-formprovider-memo- Wrap FormProvider children with React.memoadv-devtools-performance- Disable DevTools in production and during performance testingadv-testing-wrapper- Create test wrapper with QueryClient and AuthProvider
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
- Reference files:
references/{prefix}-{slug}.md
Related Skills
- For schema validation with Zod resolver, see
zodskill - For React 19 server actions, see
react-19skill - For UI/UX form design, see
frontend-designskill
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
React Hook Form
Version 1.2.0 Community May 2026
Note: This document targets React Hook Form codebases.
It is mainly for agents and LLMs to follow when maintaining, generating, or refactoring forms.
Humans may also find it useful, but guidance here is optimized for automation and consistency
by AI-assisted workflows.
---
Abstract
Comprehensive performance optimization guide for React Hook Form applications, designed for AI agents and LLMs. Contains 45 rules across 8 categories, prioritized by impact from critical (form configuration, field subscriptions, async submit lifecycle) to incremental (advanced patterns). Covers the v7.55+ subscribe() API, server error handling via setError('root.*'), and the disabled register option. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Form Configuration — CRITICAL
- 1.1 Always Provide defaultValues for Form Initialization — CRITICAL (prevents undefined state bugs and enables reset() functionality)
- 1.2 Avoid useForm Return Object in useEffect Dependencies — CRITICAL (prevents infinite render loops)
- 1.3 Enable shouldUnregister for Dynamic Form Memory Efficiency — HIGH (reduces memory usage for forms with frequently mounted/unmounted fields)
- 1.4 Consider reValidateMode for Expensive Validation — MEDIUM (trade-off between immediate corrective feedback and validation cost)
- 1.5 Understand That register's disabled Prop Clears the Value — MEDIUM (prevents lost field values and silently skipped validation)
- 1.6 Use Async defaultValues for Server Data — CRITICAL (eliminates manual useEffect reset patterns)
- 1.7 Use onSubmit Mode for Optimal Performance — CRITICAL (prevents re-renders on every keystroke)
2. Field Subscription — CRITICAL
- 2.1 Avoid Calling watch() in Render for One-Time Reads — HIGH (prevents unnecessary subscriptions and re-renders)
- 2.2 Combine useWatch with getValues for Timing Safety — HIGH (prevents missed updates due to subscription timing)
- 2.3 Provide defaultValue to useWatch for Initial Render — MEDIUM-HIGH (prevents undefined flash on initial render)
- 2.4 Subscribe Deep in Component Tree Where Data Is Needed — CRITICAL (prevents parent re-renders from propagating to unrelated children)
- 2.5 Use subscribe() to React to Form Changes Outside the React Lifecycle — HIGH (zero re-renders for non-UI consumers like analytics, autosave, telemetry)
- 2.6 Use useFormContext Sparingly for Deep Nesting — MEDIUM (reduces prop drilling but increases implicit dependencies)
- 2.7 Use useWatch Instead of watch for Isolated Re-renders — CRITICAL (reduces re-renders by 10-50× in complex forms with multiple watchers)
- 2.8 Watch Specific Fields Instead of Entire Form — CRITICAL (reduces re-renders from N fields to 1 field change)
3. Controlled Components — HIGH
- 3.1 Avoid Double Registration with useController — HIGH (prevents duplicate state management and validation bugs)
- 3.2 Combine Local State with useController for UI-Only State — MEDIUM (reduces form re-renders by 50%+ when UI state changes don't affect form data)
- 3.3 Use Single useController Per Component — MEDIUM-HIGH (prevents prop name collisions and simplifies component logic)
- 3.4 Isolate Controlled Inputs in Dedicated Child Components — HIGH (reduces re-renders from O(n) to O(1) per field change)
- 3.5 Wire Controller Field Props Correctly for UI Libraries — HIGH (prevents form binding bugs and eliminates silent failures in 100% of UI library integrations)
4. Validation Patterns — HIGH
- 4.1 Access Errors via Optional Chaining or Lodash Get — MEDIUM-HIGH (prevents runtime errors from undefined nested properties)
- 4.2 Consider Native Validation for Simple Forms — MEDIUM (reduces JavaScript validation overhead for basic constraints)
- 4.3 Define Schema Outside Component for Resolver Caching — HIGH (prevents schema recreation on every render)
- 4.4 Prefer Resolver Over Inline Validation for Complex Rules — HIGH (centralizes validation logic and enables type inference)
- 4.5 Surface Server Errors via setError('root.serverError', ...) — HIGH (prevents lost server-side validation errors and unrecoverable form state)
- 4.6 Use delayError to Debounce Rapid Error Display — MEDIUM (reduces UI flicker during fast typing validation)
- 4.7 Use Schema Factory for Dynamic Validation — HIGH (enables context-dependent validation without render-time schema creation)
5. Field Arrays — MEDIUM-HIGH
- 5.1 Provide Complete Default Objects for Field Array Operations — HIGH (prevents partial data and validation failures)
- 5.2 Separate Sequential Field Array Operations — MEDIUM-HIGH (prevents state corruption from batched mutations)
- 5.3 Use field.id as Key in useFieldArray Maps — MEDIUM-HIGH (prevents state corruption and unnecessary re-renders)
- 5.4 Use FormProvider for Virtualized Field Arrays — MEDIUM (maintains field state when rows exit/enter viewport)
- 5.5 Use Single useFieldArray Instance Per Field Name — MEDIUM-HIGH (prevents state conflicts from duplicate subscriptions)
6. State Management — MEDIUM
- 6.1 Avoid isValid with onSubmit Mode for Button State — MEDIUM (prevents validation on every render for button disabled state)
- 6.2 Destructure formState Properties Before Render — MEDIUM (enables Proxy subscription optimization)
- 6.3 Subscribe to Specific Field Names in useFormState — MEDIUM (reduces re-renders to only relevant field changes)
- 6.4 Use getFieldState for Single Field State Access — MEDIUM (avoids subscription overhead for one-time state reads)
- 6.5 Use useFormState for Isolated State Subscriptions — MEDIUM (prevents parent re-renders from state access in children)
- 6.6 Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful — HIGH (prevents stuck isSubmitting state and missing post-success reset)
7. Integration Patterns — MEDIUM
- 7.1 Transform Values at Controller Level for Type Coercion — MEDIUM (prevents type coercion bugs in 100% of numeric/date form fields)
- 7.2 Use Controller for Material-UI Components — MEDIUM (maintains controlled component behavior with proper event handling)
- 7.3 Verify shadcn Form Component Import Source — MEDIUM (prevents silent component mismatch bugs)
- 7.4 Wire shadcn Select with onValueChange Instead of Spread — MEDIUM (prevents 100% of silent select binding failures with Radix-based components)
8. Advanced Patterns — LOW
- 8.1 Create Test Wrapper with QueryClient and AuthProvider — LOW (enables proper hook testing with required context providers)
- 8.2 Disable DevTools in Production and During Performance Testing — LOW (eliminates DevTools overhead during profiling)
- 8.3 Wrap FormProvider Children with React.memo — LOW (prevents cascade re-renders from FormProvider state updates)
---
References
1. https://react-hook-form.com/docs 2. https://react-hook-form.com/advanced-usage 3. https://react-hook-form.com/docs/useform 4. https://react-hook-form.com/docs/useform/subscribe 5. https://react-hook-form.com/docs/useform/seterror 6. https://react-hook-form.com/docs/useform/formstate 7. https://react-hook-form.com/docs/usewatch 8. https://react-hook-form.com/docs/usecontroller 9. https://react-hook-form.com/docs/usefieldarray 10. https://react-hook-form.com/docs/useformstate 11. https://github.com/react-hook-form/resolvers 12. https://ui.shadcn.com/docs/components/form
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
Incorrect (description of what's wrong):
// Bad code example here
const bad = example()Correct (description of what's right):
// Good code example here
const good = example()Reference: Link to documentation or resource
{
"version": "1.2.0",
"organization": "Community",
"technology": "React Hook Form",
"date": "May 2026",
"abstract": "Comprehensive performance optimization guide for React Hook Form applications, designed for AI agents and LLMs. Contains 45 rules across 8 categories, prioritized by impact from critical (form configuration, field subscriptions, async submit lifecycle) to incremental (advanced patterns). Covers the v7.55+ subscribe() API, server error handling via setError('root.*'), and the disabled register option. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://react-hook-form.com/docs",
"https://react-hook-form.com/advanced-usage",
"https://react-hook-form.com/docs/useform",
"https://react-hook-form.com/docs/useform/subscribe",
"https://react-hook-form.com/docs/useform/seterror",
"https://react-hook-form.com/docs/useform/formstate",
"https://react-hook-form.com/docs/usewatch",
"https://react-hook-form.com/docs/usecontroller",
"https://react-hook-form.com/docs/usefieldarray",
"https://react-hook-form.com/docs/useformstate",
"https://github.com/react-hook-form/resolvers",
"https://ui.shadcn.com/docs/components/form"
],
"category": "Frontend"
}
React Hook Form Best Practices Skill
Performance optimization guidelines for React Hook Form applications.
Overview
This skill provides 41 performance rules across 8 categories, designed to help AI agents and developers write performant React Hook Form code.
Directory Structure
react-hook-form/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, org, references
├── README.md # This file
└── rules/
├── _sections.md # Category definitions
├── _template.md # Rule template
├── config-*.md # Form configuration rules (6)
├── sub-*.md # Field subscription rules (7)
├── ctrl-*.md # Controlled component rules (5)
├── valid-*.md # Validation pattern rules (6)
├── array-*.md # Field array rules (5)
├── state-*.md # State management rules (5)
├── integ-*.md # Integration pattern rules (4)
└── adv-*.md # Advanced pattern rules (3)Getting Started
Installation
pnpm installBuild AGENTS.md
pnpm buildValidate Skill
pnpm validateCreating a New Rule
1. Choose the appropriate category prefix:
| Category | Prefix | Impact |
|---|---|---|
| Form Configuration | config- | CRITICAL |
| Field Subscription | sub- | CRITICAL |
| Controlled Components | ctrl- | HIGH |
| Validation Patterns | valid- | HIGH |
| Field Arrays | array- | MEDIUM-HIGH |
| State Management | state- | MEDIUM |
| Integration Patterns | integ- | MEDIUM |
| Advanced Patterns | adv- | LOW |
2. Create a new file: rules/{prefix}-{description}.md
3. Use the rule template from rules/_template.md
Rule File Structure
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, keyword1, keyword2
---
## Rule Title Here
Brief explanation of WHY this matters (1-3 sentences).
**Incorrect (description of problem):**
\`\`\`typescript
// Bad code with comment on key line
\`\`\`
**Correct (description of solution):**
\`\`\`typescript
// Good code with minimal diff from incorrect
\`\`\`
Reference: [Documentation Link](https://example.com)File Naming Convention
Rules follow the pattern: {prefix}-{slug}.md
- prefix: Category identifier (3-8 chars) from
_sections.md - slug: Kebab-case description of the rule
Examples:
config-validation-mode.mdsub-usewatch-over-watch.mdctrl-usecontroller-isolation.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Cascade effect on entire form performance |
| HIGH | Significant impact on specific operations |
| MEDIUM-HIGH | Notable improvement for common patterns |
| MEDIUM | Measurable improvement in specific scenarios |
| LOW-MEDIUM | Minor optimization for edge cases |
| LOW | Best practice with minimal performance impact |
Scripts
| Command | Description |
|---|---|
pnpm build | Compile rules into AGENTS.md |
pnpm validate | Check skill against quality guidelines |
Contributing
1. Read existing rules in the same category for style consistency 2. Ensure incorrect/correct examples have minimal diff 3. Quantify impact where possible 4. Include authoritative reference links 5. Run validation before submitting
Acknowledgments
Based on official React Hook Form documentation and community best practices.
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Form Configuration (formcfg)
Impact: CRITICAL Description: Initial useForm setup determines validation timing, re-render boundaries, and default value caching. Incorrect mode selection causes re-renders on every keystroke.
2. Field Subscription (sub)
Impact: CRITICAL Description: Isolating field subscriptions prevents cascade re-renders across the form tree. Using watch() at root vs useWatch() in children is the #1 performance differentiator.
3. Controlled Components (ctrl)
Impact: HIGH Description: Proper Controller/useController usage isolates re-renders to individual fields. Incorrect patterns cause N×M re-renders with controlled UI libraries.
4. Validation Patterns (valid)
Impact: HIGH Description: Schema resolver caching, validation mode selection, and error handling patterns affect validation cost per keystroke.
5. Field Arrays (array)
Impact: MEDIUM-HIGH Description: Dynamic field management requires proper key handling and state isolation to prevent stale data and excess re-renders during CRUD operations.
6. State Management (formstate)
Impact: MEDIUM Description: FormState access via Proxy subscription optimization requires explicit destructuring. Accessing entire formState object disables optimization.
7. Integration Patterns (integ)
Impact: MEDIUM Description: Third-party UI library integration (MUI, shadcn, Ant Design) requires specific wiring patterns to maintain uncontrolled component benefits.
8. Advanced Patterns (adv)
Impact: LOW Description: FormProvider optimization with React.memo, DevTools performance impact awareness, and testing patterns for hook-based forms.
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):
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):
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):
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
Wrap FormProvider Children with React.memo
FormProvider triggers re-renders on form state updates. Wrap expensive child components with React.memo to prevent unnecessary re-renders when their props haven't changed.
Incorrect (children re-render on any form state change):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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):
const [pendingRemoval, setPendingRemoval] = useState<number | null>(null)
useEffect(() => {
if (pendingRemoval !== null) {
remove(pendingRemoval)
setPendingRemoval(null)
}
}, [pendingRemoval, remove])Reference: useFieldArray
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):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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
Use Single useController Per Component
Each component should use at most one useController. Multiple useControllers in a single component cause prop name collisions and complex state management. Split into separate components instead.
Incorrect (multiple useControllers cause collisions):
function DateRangeInput({ control }: { control: Control<FormData> }) {
const startField = useController({ name: 'startDate', control })
const endField = useController({ name: 'endDate', control }) // Prop names collide
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>
)
}Correct (separate components for each controlled field):
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
Isolate Controlled Inputs in Dedicated Child Components
Controller and useController are equivalent — Controller is a thin component wrapper around useController. Re-render isolation does not come from picking one over the other. It comes from putting the subscription in a child component, so that when the field value changes, only the child re-renders. Inlining Controller (or useController) in the parent form makes every parent re-render flow through every controlled input.
Incorrect (Controllers inlined in parent — every parent re-render re-renders all controlled inputs):
function PaymentForm() {
const { control, handleSubmit } = useForm<PaymentFormData>()
return (
<form onSubmit={handleSubmit(submitPayment)}>
<Controller
name="amount"
control={control}
render={({ field }) => <CurrencyInput {...field} />}
/>
<Controller
name="currency"
control={control}
render={({ field }) => <CurrencySelect {...field} />}
/>
</form>
)
}Correct (subscription moved into dedicated child components, isolating re-renders to the changed field):
function PaymentForm() {
const { control, handleSubmit } = useForm<PaymentFormData>()
return (
<form onSubmit={handleSubmit(submitPayment)}>
<AmountInput control={control} />
<CurrencySelectField control={control} />
</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} />
}Equivalent with `Controller` (also correct — same isolation):
function AmountField({ control }: { control: Control<PaymentFormData> }) {
return (
<Controller
name="amount"
control={control}
render={({ field }) => <CurrencyInput {...field} />}
/>
)
}When to prefer one API over the other:
useController— when you also needfieldState/formStatein the same component, or want to compose with custom logicController— when you want a single JSX-only declaration and don't need to read state in the surrounding component
Both achieve the same re-render isolation when placed in a child component.
Reference: useController · Controller
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):
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):
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
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):
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):
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
Understand That register's disabled Prop Clears the Value
Passing disabled: true to register (or to useController/Controller) makes the field's value become undefined in the form state and skips its validation. This is the documented behavior — RHF treats a disabled field as "not part of submission." It is not the same as <input disabled> for purely visual disabling. If you only want the input greyed out, use the plain HTML attribute. Use register('name', { disabled: true }) only when you intentionally want the field excluded from submission and validation.
Incorrect (using register's disabled option for visual disabling — the user's typed value disappears from form state):
function CheckoutForm() {
const [usingGiftCard, setUsingGiftCard] = useState(false)
const { register, handleSubmit } = useForm<CheckoutFormData>({
defaultValues: { promoCode: '', giftCardCode: '' },
})
return (
<form onSubmit={handleSubmit(submitCheckout)}>
<label>
<input type="checkbox" onChange={(e) => setUsingGiftCard(e.target.checked)} />
Use a gift card
</label>
<input
{...register('promoCode', { disabled: usingGiftCard })}
// When usingGiftCard flips true, promoCode becomes undefined in form state.
// Validation is skipped, and the user's typed value is gone if they toggle back.
/>
<input {...register('giftCardCode', { disabled: !usingGiftCard })} />
</form>
)
}Correct (use HTML disabled for visual-only disable; use register's disabled only when intentionally excluding the field):
function CheckoutForm() {
const [usingGiftCard, setUsingGiftCard] = useState(false)
const { register, handleSubmit, watch } = useForm<CheckoutFormData & { useShippingForBilling: boolean }>({
defaultValues: { promoCode: '', giftCardCode: '', useShippingForBilling: true, billingAddress: '' },
})
const useShippingForBilling = watch('useShippingForBilling')
return (
<form onSubmit={handleSubmit(submitCheckout)}>
<label>
<input type="checkbox" onChange={(e) => setUsingGiftCard(e.target.checked)} />
Use a gift card
</label>
{/* Visual disable only: value stays in form state, validation still runs */}
<input {...register('promoCode')} disabled={usingGiftCard} />
<input {...register('giftCardCode')} disabled={!usingGiftCard} />
{/* Intentional exclusion: when checked, billingAddress is omitted from submission */}
<label>
<input type="checkbox" {...register('useShippingForBilling')} />
Billing same as shipping
</label>
<input
{...register('billingAddress', {
disabled: useShippingForBilling,
required: !useShippingForBilling,
})}
/>
</form>
)
}Rule of thumb:
- Want the field greyed out but still submitted/validated → use the HTML
disabledattribute directly on the input - Want the field excluded from submission and validation → use
register('name', { disabled: true })
Reference: register - disabled
Keep Default reValidateMode Unless Validation Is Expensive
After the first submit, reValidateMode controls when fields re-validate. The default is onChange, which gives users immediate positive feedback the moment they fix an error — this is the recommended UX in most cases ("don't eagerly scold, but eagerly reward"). Only switch to onBlur or onSubmit when validation is genuinely expensive (async checks, large schemas, heavy regex on long inputs).
Incorrect (switching reValidateMode to onBlur for a cheap synchronous schema):
function CheckoutForm() {
const { register, handleSubmit } = useForm<CheckoutFormData>({
mode: 'onSubmit',
reValidateMode: 'onBlur', // Hurts UX: user fixes a wrong CVV and gets no feedback until blur
resolver: zodResolver(cheapSyncSchema),
})
return (
<form onSubmit={handleSubmit(placeOrder)}>
<input {...register('cardNumber')} />
<input {...register('cvv')} />
</form>
)
}Correct (default onChange revalidation; switch only when validation is genuinely expensive):
function CheckoutForm() {
const { register, handleSubmit } = useForm<CheckoutFormData>({
mode: 'onSubmit',
// reValidateMode: 'onChange' is the default — leave it for immediate feedback on correction.
// Switch to 'onBlur' only if you have an async check or >16ms-per-keystroke validation cost.
resolver: zodResolver(cheapSyncSchema),
})
return (
<form onSubmit={handleSubmit(placeOrder)}>
<input {...register('cardNumber')} />
<input {...register('cvv')} />
</form>
)
}When to deviate from the default:
- Validation involves a network call or expensive computation (>16ms per keystroke)
- The form has dozens of fields and post-submit re-render cost is measurable in profiling
- The error message is purely informational, not correctable in real time
Otherwise keep onChange — users who just fixed an error get instant validation success, which is the UX the RHF defaults are tuned for.
Reference: useForm - reValidateMode
Enable shouldUnregister for Dynamic Form Memory Efficiency
By default, unmounted fields retain their values and validation state. For forms with frequently added/removed fields, enable shouldUnregister to automatically clean up unmounted fields.
Incorrect (unmounted fields persist in memory):
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):
const { register, handleSubmit } = useForm({
shouldUnregister: true, // Unmounted fields removed from 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 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
Reference: useForm - shouldUnregister
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):
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):
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
Use onSubmit Mode for Optimal Performance
The mode option in useForm determines when validation runs. Using onChange mode triggers validation on every keystroke, causing significant re-renders. Default to onSubmit unless real-time feedback is essential.
Incorrect (validates on every keystroke):
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>
)
}Correct (validates only on submit):
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: LikeonBlurbut only after first interactiononChange: Only when real-time validation feedback is critical (use sparingly)
Reference: useForm - mode
Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
isSubmitting is the canonical way to disable the submit button while a request is in flight, but it has a well-known footgun: if your submit handler throws, isSubmitting stays true and the form becomes unrecoverable. Always try/catch inside the async handler. Pair this with isSubmitSuccessful + useEffect(reset) to clear the form after a successful submit (resetting inside the handler races with the success state transition).
Incorrect (throw leaves isSubmitting stuck; manual reset races):
function CreatePostForm() {
const { register, handleSubmit, reset, formState: { isSubmitting } } = useForm<PostFormData>()
const onSubmit = async (data: PostFormData) => {
const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) throw new Error('Save failed') // isSubmitting will stay true forever
reset() // Races with the form's success state
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} />
<textarea {...register('body')} />
<button disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Save'}</button>
</form>
)
}Correct (try/catch keeps form recoverable; useEffect resets after success):
function CreatePostForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { isSubmitting, isSubmitSuccessful, errors },
} = useForm<PostFormData>()
const onSubmit = async (data: PostFormData) => {
try {
const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
setError('root.serverError', { type: 'server', message: 'Save failed' })
}
} catch {
setError('root.serverError', { type: 'network', message: 'Network error — please retry' })
}
}
// Reset after a successful submit completes — runs once per success transition
useEffect(() => {
if (isSubmitSuccessful) reset()
}, [isSubmitSuccessful, reset])
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root?.serverError && <div role="alert">{errors.root.serverError.message}</div>}
<input {...register('title')} />
<textarea {...register('body')} />
<button disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Save'}</button>
</form>
)
}Key details:
isSubmittingresets only when the handler returns (resolves). A throw leaves ittrueand the form unrecoverableisSubmitSuccessfulbecomestruewhen the handler completes without throwing and without callingsetError. Use it to gate the post-success reset- Calling
reset()inside the submit handler races with React's commit ofisSubmitSuccessful; theuseEffectform is the documented pattern - If you want to preserve specific fields across reset, pass them:
reset(undefined, { keepDirtyValues: true })orreset({ defaultValue: lastSaved })
Reference: formState · reset · Discussion #10103 — isSubmitting does not recover when submit handler throws
Avoid isValid with onSubmit Mode for Button State
When using mode: 'onSubmit', accessing isValid forces validation on every render to determine the current validity state. This defeats the purpose of deferred validation.
Incorrect (isValid triggers validation despite onSubmit mode):
function RegistrationForm() {
const { register, handleSubmit, formState: { isValid } } = useForm({
mode: 'onSubmit', // Expects validation only on submit
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
<button disabled={!isValid}>Register</button> {/* Forces validation on every render */}
</form>
)
}Correct (use isSubmitting or allow submit attempt):
function RegistrationForm() {
const { register, handleSubmit, formState: { isSubmitting } } = useForm({
mode: 'onSubmit',
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
<button disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}Alternative (use onChange mode if real-time validation needed):
const { formState: { isValid } } = useForm({
mode: 'onChange', // Explicit: validation runs on every change
})Reference: useForm - mode
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):
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):
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
Use getFieldState for Single Field State Access
When you need to check a single field's state (dirty, touched, error) without subscribing to updates, use getFieldState(). It returns current state without creating a subscription.
Incorrect (useFormState creates subscription for one-time check):
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 non-reactive read):
function FieldHelpText({ formState, name }: { formState: FormState; name: string }) {
const { isTouched } = getFieldState(name, formState) // No subscription created
return isTouched ? null : <span>Please fill out this field</span>
}
function MyForm() {
const { register, formState } = useForm()
return (
<form>
<input {...register('email')} />
<FieldHelpText formState={formState} name="email" />
</form>
)
}When to use each:
useFormState: Need to react to state changes (display updates)getFieldState: Need current state at a point in time (conditional logic)
Reference: useForm - getFieldState
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):
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):
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:
const { errors } = useFormState({
control,
name: ['email', 'password'], // Subscribe to multiple specific fields
})Reference: useFormState
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):
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):
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>
)
}
function SaveIndicator({ control }: { control: Control }) {
const { isDirty } = useFormState({ control })
return isDirty ? <span>Unsaved changes</span> : null
}Reference: useFormState
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):
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):
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
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):
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):
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
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):
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):
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
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):
function QuantityInput() {
const { register } = useForm()
return (
<input
{...register('quantity', { valueAsNumber: true })} // Returns NaN for empty string
type="number"
/>
)
}Correct (explicit transformation in Controller):
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):
const schema = z.object({
quantity: z.string().transform((val) => (val === '' ? null : parseInt(val, 10))),
})Reference: React Hook Form - Advanced Usage
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):
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):
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
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):
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):
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
Use subscribe() to React to Form Changes Outside the React Lifecycle
Introduced in v7.55.0, useForm().subscribe(...) registers a callback that fires on form state or value changes without causing any re-renders. Use it when the consumer of the change is not a UI element — analytics, autosave to localStorage, debounced telemetry, sending drafts to a server. useWatch and watch are still right for things that paint to screen; subscribe is right for everything else.
Incorrect (using useWatch to drive a non-UI side-effect — re-renders the form on every keystroke):
function ProfileForm() {
const { register, handleSubmit, control } = useForm<ProfileFormData>()
const values = useWatch({ control }) // Every keystroke re-renders ProfileForm
useEffect(() => {
analytics.track('profile_field_edited', { values }) // Fires on every render
}, [values])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
<input {...register('bio')} />
</form>
)
}Correct (subscribe() runs the side-effect with zero re-renders):
function ProfileForm() {
const { register, handleSubmit, subscribe } = useForm<ProfileFormData>()
useEffect(() => {
const unsubscribe = subscribe({
formState: { values: true },
callback: ({ values, name }) => {
analytics.track('profile_field_edited', { field: name, values })
},
})
return unsubscribe
}, [subscribe])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
<input {...register('bio')} />
</form>
)
}Subscribing to specific fields with formState slices (e.g. dirty-aware autosave):
function DraftEditor() {
const { register, subscribe } = useForm<DraftFormData>({
defaultValues: loadDraft(),
})
useEffect(() => {
const unsubscribe = subscribe({
name: ['title', 'body'],
formState: { values: true, isDirty: true },
callback: ({ values, isDirty }) => {
if (isDirty) debouncedSaveDraft(values)
},
})
return unsubscribe
}, [subscribe])
return (
<>
<input {...register('title')} />
<textarea {...register('body')} />
</>
)
}When to use which:
useWatch/Controller— the value drives a rendered elementsubscribe— the value drives a non-UI side-effect (analytics, autosave, localStorage sync, telemetry)watch(callback)— legacy callback form; prefersubscribein new code (subscribe replaces the watch-callback pattern with explicit formState slicing and no implicit re-renders)
subscribe returns an unsubscribe function — always return it from the useEffect cleanup to avoid leaks across remounts.
Reference: subscribe · Release notes v7.55.0
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):
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):
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
Provide defaultValue to useWatch for Initial Render
useWatch returns undefined on the first render before the subscription is established. Provide a defaultValue to prevent undefined checks and potential UI flicker.
Incorrect (undefined on first render):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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
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):
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):
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
onChangemode - Fields with character count requirements
- Search inputs with minimum length
Reference: useForm - delayError
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):
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):
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
Access Errors via Optional Chaining or Lodash Get
Error objects can have deeply nested paths for nested fields. Use optional chaining or lodash get() to safely access error messages without runtime errors.
Incorrect (direct access throws on undefined):
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):
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>
)
}Alternative (lodash get for complex paths):
import { get } from 'lodash'
function AddressForm() {
const { register, formState: { errors } } = useForm()
return (
<form>
<input {...register('address.street', { required: true })} />
<span>{get(errors, 'address.street.message')}</span>
</form>
)
}Reference: React Hook Form - Advanced Usage
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):
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):
const checkoutSchema = z.object({
email: z.string().email('Invalid email'),
billingAddressSame: z.boolean(),
billingStreet: z.string().optional(),
}).refine(
(data) => data.billingAddressSame || data.billingStreet,
{ message: '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
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):
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):
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
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):
function RegistrationForm() {
const schema = z.object({ // Created fresh on every render
email: z.string().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):
const registrationSchema = z.object({ // Created once at module load
email: z.string().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
Surface Server Errors via setError('root.serverError', ...)
handleSubmit does not catch errors thrown inside async submit handlers — it logs them and silently leaves the form unrecoverable (isSubmitting stays true if you throw). The canonical pattern is to try/catch inside the submit handler and route API failures into setError. Use field-level setError(name, ...) when the server tells you which field is wrong; use setError('root.serverError', ...) for general failures (network error, 500, "Account is locked").
Incorrect (server error is thrown, swallowed, and form is now stuck):
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) throw new Error('Login failed') // Lost: no UI feedback, isSubmitting stuck
redirect('/dashboard')
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input type="password" {...register('password')} />
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}Correct (server errors surfaced via setError, form stays recoverable):
function LoginForm() {
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
clearErrors('root.serverError')
try {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const body = await res.json()
if (body.field === 'password') {
setError('password', { type: 'server', message: body.message })
} else {
setError('root.serverError', { type: 'server', message: body.message ?? 'Sign in failed' })
}
return
}
redirect('/dashboard')
} catch {
setError('root.serverError', { type: 'network', message: 'Network error — please retry' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root?.serverError && (
<div role="alert">{errors.root.serverError.message}</div>
)}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}Key details:
- Root-level errors live under
errors.root.{key}— pick any key (serverError,network,rateLimit) and read it back the same way - Root errors persist across submissions until you call
clearErrors('root.serverError')— clear at the start of each submit, or rely on the next resolver pass to overwrite - Always
try/catchasync submit handlers.handleSubmitwill not surface thrown errors, andisSubmittingonly resets when the handler returns (resolves), not when it throws — see alsoformstate-async-submit-lifecycle
Reference: setError · Discussion #9691 — Handle global/server errors
Related skills
How it compares
Use react-hook-form when enforcing React Hook Form-specific performance rules rather than generic React component guidance.
FAQ
Which validation mode is recommended?
onSubmit mode for optimal performance unless UX requires earlier validation.
How do I limit re-renders on value changes?
Prefer useWatch or subscribe() over watch for granular field subscriptions.
Does this cover Server Actions?
No. Use the react-19 skill for Server Actions and useActionState.
Is React Hook Form safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.