Added AI skills
This commit is contained in:
96
.agents/skills/react-hook-form/AGENTS.md
Normal file
96
.agents/skills/react-hook-form/AGENTS.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# React Hook Form
|
||||
|
||||
**Version 2.1.0**
|
||||
Community
|
||||
July 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
|
||||
|
||||
Focused guide to the React Hook Form decisions a capable model gets wrong. Contains 35 rules across 7 categories, verified against react-hook-form 7.82.0 by diffing the shipped type definitions and type-checking every code block under tsc --strict against the real package types. Covers where a subscription must live to isolate re-renders, the third useForm generic that transforming resolvers require, the options that silently drop data from the submitted payload (register disabled, shouldUnregister, useFieldArray disabled), the NaN that valueAsNumber produces for an empty input, server error handling via setError('root.*'), resetDefaultValues() for rebasing the dirty baseline after a save, and the Watch / FormStateSubscribe / FieldArray render-prop components. Rules that merely restate what the library already does correctly have been removed.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Form Configuration](references/_sections.md#1-form-configuration) — **CRITICAL**
|
||||
- 1.1 [Always Provide defaultValues for Form Initialization](references/formcfg-default-values.md) — CRITICAL (prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore)
|
||||
- 1.2 [Depend on formState Slices, Not on formState Itself](references/formcfg-useeffect-dependency.md) — HIGH (prevents effects that re-run on every keystroke)
|
||||
- 1.3 [Justify Any mode Other Than the Default onSubmit](references/formcfg-validation-mode.md) — CRITICAL (prevents a full validation pass and re-render on every keystroke)
|
||||
- 1.4 [Keep Default reValidateMode Unless Validation Is Expensive](references/formcfg-revalidate-mode.md) — MEDIUM (maintains immediate corrective feedback after first submit)
|
||||
- 1.5 [Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload](references/formcfg-should-unregister.md) — HIGH (prevents silently dropping values the user already entered)
|
||||
- 1.6 [Pass the Third useForm Generic When the Resolver Transforms Values](references/formcfg-transformed-values-generic.md) — CRITICAL (makes handleSubmit receive the schema's output type instead of its input type)
|
||||
- 1.7 [Use Async defaultValues for Server Data](references/formcfg-async-default-values.md) — CRITICAL (eliminates manual useEffect reset patterns)
|
||||
- 1.8 [Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option](references/formcfg-disabled-prop.md) — MEDIUM (prevents fields silently missing from submission and skipped validation)
|
||||
- 1.9 [Use the values Prop to Keep a Form in Sync with Server Data](references/formcfg-values-prop.md) — HIGH (replaces a useEffect+reset that overwrites edits whenever the query refetches)
|
||||
2. [Field Subscription](references/_sections.md#2-field-subscription) — **CRITICAL**
|
||||
- 2.1 [Avoid Calling watch() in Render for One-Time Reads](references/sub-avoid-watch-in-render.md) — HIGH (prevents unnecessary subscriptions and re-renders)
|
||||
- 2.2 [React.memo Cannot Stop Context-Driven Re-renders Under FormProvider](references/sub-memo-cannot-beat-context.md) — MEDIUM (replaces a memo pass that has no effect with isolation that does)
|
||||
- 2.3 [Use subscribe() to React to Form Changes Outside the React Lifecycle](references/sub-subscribe-outside-react.md) — HIGH (eliminates re-renders for non-UI consumers like analytics, autosave, telemetry)
|
||||
- 2.4 [Use the Render-Prop Components to Isolate Re-renders Without a Child Component](references/sub-render-prop-components.md) — HIGH (confines a subscription to one subtree without authoring a wrapper component)
|
||||
- 2.5 [Use useFormContext Sparingly for Deep Nesting](references/sub-useformcontext-sparingly.md) — MEDIUM (reduces prop drilling but increases implicit dependencies)
|
||||
- 2.6 [Use useWatch Instead of watch for Isolated Re-renders](references/sub-usewatch-over-watch.md) — CRITICAL (confines value-change re-renders to the subscribing component)
|
||||
- 2.7 [Watch Specific Fields Instead of Entire Form](references/sub-watch-specific-fields.md) — CRITICAL (reduces re-renders from N fields to 1 field change)
|
||||
3. [Controlled Components](references/_sections.md#3-controlled-components) — **HIGH**
|
||||
- 3.1 [Isolate Controlled Inputs in Dedicated Child Components](references/ctrl-usecontroller-isolation.md) — HIGH (re-renders only the changed field instead of the whole form)
|
||||
- 3.2 [Wire Controller Field Props Correctly for UI Libraries](references/ctrl-controller-field-props.md) — HIGH (prevents a control that renders correctly but never writes back to the form)
|
||||
4. [Validation Patterns](references/_sections.md#4-validation-patterns) — **HIGH**
|
||||
- 4.1 [Build the Validation Schema Once, Outside the Render Path](references/valid-resolver-caching.md) — HIGH (stops rebuilding the whole schema object on every keystroke)
|
||||
- 4.2 [Handle the NaN valueAsNumber Produces for an Empty Input](references/valid-valueasnumber-empty-nan.md) — HIGH (prevents an optional number field that can never be left blank)
|
||||
- 4.3 [Surface Server Errors via setError('root.serverError', ...)](references/valid-server-errors.md) — HIGH (prevents lost server-side validation errors and unrecoverable form state)
|
||||
- 4.4 [Use delayError to Debounce Rapid Error Display](references/valid-delay-error.md) — MEDIUM (reduces UI flicker during fast typing validation)
|
||||
5. [State Management](references/_sections.md#5-state-management) — **MEDIUM-HIGH**
|
||||
- 5.1 [Avoid isValid with onSubmit Mode for Button State](references/formstate-avoid-isvalid-with-onsubmit.md) — MEDIUM (prevents whole-form validation on every change under a deferred-validation mode)
|
||||
- 5.2 [Read Every formState Property You Depend On During Render](references/formstate-destructure-formstate.md) — MEDIUM (prevents a component that never re-renders when the state it shows changes)
|
||||
- 5.3 [Rebase Defaults with resetDefaultValues After a Successful Save](references/formstate-reset-default-values.md) — HIGH (clears isDirty without discarding edits made during the in-flight request)
|
||||
- 5.4 [Use handleSubmit's Second Argument to Handle a Rejected Submit](references/formstate-handlesubmit-oninvalid.md) — MEDIUM (gives a failed submit somewhere to go instead of silently doing nothing)
|
||||
- 5.5 [Use useFormState for Isolated State Subscriptions](references/formstate-useformstate-isolation.md) — MEDIUM (prevents parent re-renders from state access in children)
|
||||
- 5.6 [Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful](references/formstate-async-submit-lifecycle.md) — HIGH (prevents stuck isSubmitting state and missing post-success reset)
|
||||
6. [Field Arrays](references/_sections.md#6-field-arrays) — **MEDIUM-HIGH**
|
||||
- 6.1 [Separate Sequential Field Array Operations](references/array-separate-crud-operations.md) — MEDIUM-HIGH (prevents state corruption from batched mutations)
|
||||
- 6.2 [Use field.id as Key in useFieldArray Maps](references/array-use-field-id-as-key.md) — MEDIUM-HIGH (prevents state corruption and unnecessary re-renders)
|
||||
- 6.3 [Use Single useFieldArray Instance Per Field Name](references/array-unique-fieldarray-per-name.md) — MEDIUM-HIGH (prevents state conflicts from duplicate subscriptions)
|
||||
- 6.4 [useFieldArray's disabled Option Makes Every Mutation a Silent No-op](references/array-disabled-silently-noops.md) — MEDIUM-HIGH (prevents append/remove calls that vanish with no error or warning)
|
||||
7. [Integration Patterns](references/_sections.md#7-integration-patterns) — **MEDIUM**
|
||||
- 7.1 [Transform Values at Controller Level for Type Coercion](references/integ-value-transform.md) — MEDIUM (stops string input values reaching a number- or date-typed schema)
|
||||
- 7.2 [Verify shadcn Form Component Import Source](references/integ-shadcn-form-import.md) — MEDIUM (prevents silent component mismatch bugs)
|
||||
- 7.3 [Wire shadcn Select with onValueChange Instead of Spread](references/integ-shadcn-select-wiring.md) — MEDIUM (prevents a Radix Select that renders but never writes to the form)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. [https://react-hook-form.com/docs](https://react-hook-form.com/docs)
|
||||
2. [https://react-hook-form.com/advanced-usage](https://react-hook-form.com/advanced-usage)
|
||||
3. [https://react-hook-form.com/docs/useform](https://react-hook-form.com/docs/useform)
|
||||
4. [https://react-hook-form.com/docs/useform/subscribe](https://react-hook-form.com/docs/useform/subscribe)
|
||||
5. [https://react-hook-form.com/docs/useform/seterror](https://react-hook-form.com/docs/useform/seterror)
|
||||
6. [https://react-hook-form.com/docs/useform/setvalue](https://react-hook-form.com/docs/useform/setvalue)
|
||||
7. [https://react-hook-form.com/docs/useform/resetdefaultvalues](https://react-hook-form.com/docs/useform/resetdefaultvalues)
|
||||
8. [https://react-hook-form.com/docs/useform/formstate](https://react-hook-form.com/docs/useform/formstate)
|
||||
9. [https://react-hook-form.com/docs/usewatch](https://react-hook-form.com/docs/usewatch)
|
||||
10. [https://react-hook-form.com/docs/usecontroller](https://react-hook-form.com/docs/usecontroller)
|
||||
11. [https://react-hook-form.com/docs/usefieldarray](https://react-hook-form.com/docs/usefieldarray)
|
||||
12. [https://react-hook-form.com/docs/useformstate](https://react-hook-form.com/docs/useformstate)
|
||||
13. [https://github.com/react-hook-form/react-hook-form/releases](https://github.com/react-hook-form/react-hook-form/releases)
|
||||
14. [https://github.com/react-hook-form/resolvers](https://github.com/react-hook-form/resolvers)
|
||||
15. [https://ui.shadcn.com/docs/components/form](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](references/_sections.md) | Category definitions and impact ordering |
|
||||
| [assets/templates/_template.md](assets/templates/_template.md) | Template for creating new rules |
|
||||
| [SKILL.md](SKILL.md) | Quick reference entry point |
|
||||
| [metadata.json](metadata.json) | Version and reference URLs |
|
||||
126
.agents/skills/react-hook-form/README.md
Normal file
126
.agents/skills/react-hook-form/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# React Hook Form Best Practices Skill
|
||||
|
||||
Corrective guidance for React Hook Form — the decisions that go wrong by default.
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides 35 rules across 7 categories. Every rule names a decision that a capable model gets wrong by default; rules that merely restated correct library behaviour were removed in 2.0.0. Verified against **react-hook-form 7.82.0** (July 2026), with every code block type-checked under `tsc --strict` against the real package types.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
react-hook-form/
|
||||
├── SKILL.md # Entry point with quick reference
|
||||
├── AGENTS.md # Compiled comprehensive guide (TOC over references/)
|
||||
├── metadata.json # Version, org, references
|
||||
├── README.md # This file
|
||||
├── assets/
|
||||
│ └── templates/
|
||||
│ └── _template.md # Rule template
|
||||
└── references/
|
||||
├── _sections.md # Category definitions and impact ordering
|
||||
├── formcfg-*.md # Form configuration rules (9)
|
||||
├── sub-*.md # Field subscription rules (7)
|
||||
├── ctrl-*.md # Controlled component rules (2)
|
||||
├── valid-*.md # Validation pattern rules (4)
|
||||
├── formstate-*.md # State management rules (6)
|
||||
├── array-*.md # Field array rules (4)
|
||||
└── integ-*.md # Integration pattern rules (3)
|
||||
```
|
||||
|
||||
## Validating
|
||||
|
||||
This skill is validated by the repo-level tooling, not by per-skill scripts:
|
||||
|
||||
```bash
|
||||
npm run validate # structural validation of every skill
|
||||
node /path/to/dev-skill/scripts/validate-skill.js . # discipline-aware validation of this skill
|
||||
```
|
||||
|
||||
## Creating a New Rule
|
||||
|
||||
1. Choose the appropriate category prefix:
|
||||
|
||||
| Category | Prefix | Impact |
|
||||
|----------|--------|--------|
|
||||
| Form Configuration | `formcfg-` | CRITICAL |
|
||||
| Field Subscription | `sub-` | CRITICAL |
|
||||
| Controlled Components | `ctrl-` | HIGH |
|
||||
| Validation Patterns | `valid-` | HIGH |
|
||||
| State Management | `formstate-` | MEDIUM-HIGH |
|
||||
| Field Arrays | `array-` | MEDIUM-HIGH |
|
||||
| Integration Patterns | `integ-` | MEDIUM |
|
||||
|
||||
2. Create a new file: `references/{prefix}-{slug}.md`
|
||||
|
||||
3. Use the rule template from `assets/templates/_template.md`
|
||||
|
||||
4. Add the rule to the SKILL.md quick reference and to the AGENTS.md table of contents (entries are ordered alphabetically by title within each section).
|
||||
|
||||
## Rule File Structure
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
|
||||
impactDescription: What the rule prevents or enables
|
||||
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 from `_sections.md` (lowercase letters only)
|
||||
- **slug**: Kebab-case description of the rule
|
||||
|
||||
Examples:
|
||||
- `formcfg-validation-mode.md`
|
||||
- `sub-usewatch-over-watch.md`
|
||||
- `ctrl-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 |
|
||||
|
||||
## Companion Skill
|
||||
|
||||
`react-hook-form-audit` is a static-analysis skill that detects violations of these rules in a Next.js App Router codebase and links each finding back to the corresponding file in `references/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Read existing rules in the same category for style consistency
|
||||
2. Ensure incorrect/correct examples have minimal diff
|
||||
3. Typecheck every example against the pinned react-hook-form version before committing
|
||||
4. Include authoritative reference links
|
||||
5. Run validation before submitting
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Based on official React Hook Form documentation, the library's shipped type definitions, and community best practices.
|
||||
113
.agents/skills/react-hook-form/SKILL.md
Normal file
113
.agents/skills/react-hook-form/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: react-hook-form
|
||||
description: React Hook Form performance optimization for client-side form validation using useForm, useWatch, useController, useFieldArray, the subscribe() API, and the Watch / FormStateSubscribe / FieldArray render-prop components. Covers RHF 7.82 additions including resetDefaultValues() and the disabled field-array option. This skill should be used when building client-side controlled forms with React Hook Form library. This skill does NOT cover React 19 Server Actions, useActionState, or server-side form handling (use react-19 skill for those).
|
||||
---
|
||||
|
||||
# React Hook Form Best Practices by Community
|
||||
|
||||
Comprehensive performance optimization guide for React Hook Form applications. Contains 35 rules across 7 categories, each naming a decision that goes wrong by default. Verified against react-hook-form **7.82.0**.
|
||||
|
||||
## 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-19` skill 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>` + `FormData` is 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 | State Management | MEDIUM-HIGH | `formstate-` |
|
||||
| 6 | Field Arrays | MEDIUM-HIGH | `array-` |
|
||||
| 7 | Integration Patterns | MEDIUM | `integ-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Form Configuration (CRITICAL)
|
||||
|
||||
- `formcfg-default-values` - Always Provide defaultValues for Form Initialization
|
||||
- `formcfg-useeffect-dependency` - Depend on formState Slices, Not on formState Itself
|
||||
- `formcfg-validation-mode` - Justify Any mode Other Than the Default onSubmit
|
||||
- `formcfg-revalidate-mode` - Keep Default reValidateMode Unless Validation Is Expensive
|
||||
- `formcfg-should-unregister` - Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
|
||||
- `formcfg-transformed-values-generic` - Pass the Third useForm Generic When the Resolver Transforms Values
|
||||
- `formcfg-async-default-values` - Use Async defaultValues for Server Data
|
||||
- `formcfg-disabled-prop` - Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
|
||||
- `formcfg-values-prop` - Use the values Prop to Keep a Form in Sync with Server Data
|
||||
|
||||
### 2. Field Subscription (CRITICAL)
|
||||
|
||||
- `sub-avoid-watch-in-render` - Avoid Calling watch() in Render for One-Time Reads
|
||||
- `sub-memo-cannot-beat-context` - React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
|
||||
- `sub-subscribe-outside-react` - Use subscribe() to React to Form Changes Outside the React Lifecycle
|
||||
- `sub-render-prop-components` - Use the Render-Prop Components to Isolate Re-renders Without a Child Component
|
||||
- `sub-useformcontext-sparingly` - Use useFormContext Sparingly for Deep Nesting
|
||||
- `sub-usewatch-over-watch` - Use useWatch Instead of watch for Isolated Re-renders
|
||||
- `sub-watch-specific-fields` - Watch Specific Fields Instead of Entire Form
|
||||
|
||||
### 3. Controlled Components (HIGH)
|
||||
|
||||
- `ctrl-usecontroller-isolation` - Isolate Controlled Inputs in Dedicated Child Components
|
||||
- `ctrl-controller-field-props` - Wire Controller Field Props Correctly for UI Libraries
|
||||
|
||||
### 4. Validation Patterns (HIGH)
|
||||
|
||||
- `valid-resolver-caching` - Build the Validation Schema Once, Outside the Render Path
|
||||
- `valid-valueasnumber-empty-nan` - Handle the NaN valueAsNumber Produces for an Empty Input
|
||||
- `valid-server-errors` - Surface Server Errors via setError('root.serverError', ...)
|
||||
- `valid-delay-error` - Use delayError to Debounce Rapid Error Display
|
||||
|
||||
### 5. State Management (MEDIUM-HIGH)
|
||||
|
||||
- `formstate-avoid-isvalid-with-onsubmit` - Avoid isValid with onSubmit Mode for Button State
|
||||
- `formstate-destructure-formstate` - Read Every formState Property You Depend On During Render
|
||||
- `formstate-reset-default-values` - Rebase Defaults with resetDefaultValues After a Successful Save
|
||||
- `formstate-handlesubmit-oninvalid` - Use handleSubmit's Second Argument to Handle a Rejected Submit
|
||||
- `formstate-useformstate-isolation` - Use useFormState for Isolated State Subscriptions
|
||||
- `formstate-async-submit-lifecycle` - Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
|
||||
|
||||
### 6. Field Arrays (MEDIUM-HIGH)
|
||||
|
||||
- `array-separate-crud-operations` - Separate Sequential Field Array Operations
|
||||
- `array-use-field-id-as-key` - Use field.id as Key in useFieldArray Maps
|
||||
- `array-unique-fieldarray-per-name` - Use Single useFieldArray Instance Per Field Name
|
||||
- `array-disabled-silently-noops` - useFieldArray's disabled Option Makes Every Mutation a Silent No-op
|
||||
|
||||
### 7. Integration Patterns (MEDIUM)
|
||||
|
||||
- `integ-value-transform` - Transform Values at Controller Level for Type Coercion
|
||||
- `integ-shadcn-form-import` - Verify shadcn Form Component Import Source
|
||||
- `integ-shadcn-select-wiring` - Wire shadcn Select with onValueChange Instead of Spread
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual reference files for detailed explanations and code examples:
|
||||
|
||||
- [Section definitions](references/_sections.md) - Category structure and impact levels
|
||||
- [Rule template](assets/templates/_template.md) - Template for adding new rules
|
||||
- Reference files: `references/{prefix}-{slug}.md`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- For schema validation with Zod resolver, see `zod` skill
|
||||
- For React 19 server actions, see `react-19` skill
|
||||
- For UI/UX form design, see `frontend-design` skill
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
26
.agents/skills/react-hook-form/assets/templates/_template.md
Normal file
26
.agents/skills/react-hook-form/assets/templates/_template.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
||||
tags: prefix, tag1, tag2
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
// Bad code example here
|
||||
const bad = example()
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```typescript
|
||||
// Good code example here
|
||||
const good = example()
|
||||
```
|
||||
|
||||
Reference: [Link to documentation or resource](https://example.com)
|
||||
41
.agents/skills/react-hook-form/references/_sections.md
Normal file
41
.agents/skills/react-hook-form/references/_sections.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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 what ends up in the submitted payload. The wrong mode validates on every keystroke; the wrong disabled or shouldUnregister setting silently drops data.
|
||||
|
||||
## 2. Field Subscription (sub)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Where a subscription lives decides how much of the tree re-renders. Reading a value at the form root instead of in the leaf that displays it is the difference between re-rendering the whole form and re-rendering one field.
|
||||
|
||||
## 3. Controlled Components (ctrl)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Controller and useController isolate re-renders only when they sit in a child component. Wiring their field props to a third-party control also has to match that library's prop names, or the input renders but never writes back.
|
||||
|
||||
## 4. Validation Patterns (valid)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Where the schema is constructed, how server-side failures re-enter the form, and how error display is paced. Building a schema inside render pays the construction cost on every keystroke.
|
||||
|
||||
## 5. State Management (formstate)
|
||||
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** formState is a per-property Proxy, so a value you never read during render is a value you never re-render for. Submit lifecycle belongs here too: an async handler that throws without a catch strands isSubmitting forever.
|
||||
|
||||
## 6. Field Arrays (array)
|
||||
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Dynamic field management requires stable keys and one owner per field name. Some options — notably disabled — fail silently rather than loudly.
|
||||
|
||||
## 7. Integration Patterns (integ)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Third-party UI library integration (shadcn/Radix, MUI) requires specific wiring, and native inputs need explicit type coercion before values reach a typed schema.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: useFieldArray's disabled Option Makes Every Mutation a Silent No-op
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: prevents append/remove calls that vanish with no error or warning
|
||||
tags: array, useFieldArray, disabled, read-only, no-op
|
||||
---
|
||||
|
||||
## useFieldArray's disabled Option Makes Every Mutation a Silent No-op
|
||||
|
||||
`useFieldArray({ disabled })` (RHF 7.79+) is not a UI hint. When `disabled` is truthy, `append`, `prepend`, `insert`, `remove`, `swap`, `move`, `update`, and `replace` each return immediately — no mutation, no error, no console warning. Reaching for it as "grey the rows out while saving" produces a form where clicking Add does nothing and there is nothing in the console to explain why.
|
||||
|
||||
**Incorrect (`disabled` tied to submit state — the append silently disappears):**
|
||||
|
||||
```typescript
|
||||
function TeamMembersFields({ control }: { control: Control<TeamForm> }) {
|
||||
const { isSubmitting } = useFormState({ control })
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'members',
|
||||
disabled: isSubmitting, // Also kills append/remove, not just the visuals
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<MemberRow key={field.id} index={index} onRemove={() => remove(index)} />
|
||||
))}
|
||||
<button type="button" onClick={() => append({ email: '' })}>Add member</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (disable the controls; reserve the option for genuinely read-only arrays):**
|
||||
|
||||
```typescript
|
||||
function TeamMembersFields({ control }: { control: Control<TeamForm> }) {
|
||||
const { isSubmitting } = useFormState({ control })
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'members' })
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<MemberRow key={field.id} index={index} onRemove={() => remove(index)} disabled={isSubmitting} />
|
||||
))}
|
||||
<button type="button" onClick={() => append({ email: '' })} disabled={isSubmitting}>
|
||||
Add member
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Use `disabled: true` when the array is structurally immutable for this user — a locked invoice, a plan the current role may not edit — where a mutation slipping through would be a bug. In that case `fields[index].disabled` (7.80+) carries the flag down to each row so the inputs can render disabled from the same source of truth.
|
||||
|
||||
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Forward compatibility:** `field.id` is correct for all of v7. The v8 beta line renames the generated render key to `field.key` and drops the `keyName` option, so `id` becomes an ordinary data property that no longer guarantees uniqueness. Do not pre-emptively switch on v7 — but if you set `keyName` to something custom today, that is the piece with no v8 equivalent.
|
||||
|
||||
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Wire Controller Field Props Correctly for UI Libraries
|
||||
impact: HIGH
|
||||
impactDescription: prevents a control that renders correctly but never writes back to the form
|
||||
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<ShippingFormValues> }) {
|
||||
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<ShippingFormValues> }) {
|
||||
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)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Isolate Controlled Inputs in Dedicated Child Components
|
||||
impact: HIGH
|
||||
impactDescription: re-renders only the changed field instead of the whole form
|
||||
tags: ctrl, useController, Controller, controlled-components, re-renders
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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 need `fieldState`/`formState` in the same component, or want to compose with custom logic
|
||||
- `Controller` — 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](https://react-hook-form.com/docs/usecontroller) · [Controller](https://react-hook-form.com/docs/usecontroller/controller)
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Always Provide defaultValues for Form Initialization
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore
|
||||
tags: formcfg, default-values, initialization, useForm
|
||||
---
|
||||
|
||||
## Always Provide defaultValues for Form Initialization
|
||||
|
||||
`useForm<T>()` with no `defaultValues` starts every field as `undefined`. Three things break at once: any controlled input flips from uncontrolled to controlled on first keystroke (React logs a warning and can lose the value), `reset()` with no arguments has no baseline to restore to, and `isDirty`/`dirtyFields` compare against nothing so the form reads as dirty the moment anything is touched. Provide the full shape up front, using empty strings rather than `undefined`.
|
||||
|
||||
**Incorrect (no defaultValues — reset() has no baseline, inputs start uncontrolled):**
|
||||
|
||||
```typescript
|
||||
function ProfileForm({ user }: { user: User }) {
|
||||
const { register, reset, handleSubmit } = useForm<ProfileFormValues>()
|
||||
|
||||
useEffect(() => {
|
||||
reset(user)
|
||||
}, [user, reset])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveProfile)}>
|
||||
<input {...register('firstName')} />
|
||||
<input {...register('lastName')} />
|
||||
<button type="button" onClick={() => reset()}>Discard changes</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (explicit defaults — reset() restores them, isDirty is meaningful):**
|
||||
|
||||
```typescript
|
||||
function ProfileForm({ user }: { user: User }) {
|
||||
const { register, reset, handleSubmit } = useForm<ProfileFormValues>({
|
||||
defaultValues: { firstName: '', lastName: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset(user)
|
||||
}, [user, reset])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveProfile)}>
|
||||
<input {...register('firstName')} />
|
||||
<input {...register('lastName')} />
|
||||
<button type="button" onClick={() => reset()}>Discard changes</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
When the defaults come from the server, pass them directly rather than defaulting-then-resetting — see `formcfg-async-default-values`. After a successful save, move the baseline with `resetDefaultValues` rather than `reset` (see `formstate-reset-default-values`).
|
||||
|
||||
**Note:** Avoid custom objects with prototype methods (Moment, Luxon) as defaultValues — RHF deep-clones them. Use plain objects or primitives.
|
||||
|
||||
Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents fields silently missing from submission and skipped validation
|
||||
tags: formcfg, register, disabled, validation, footgun
|
||||
---
|
||||
|
||||
## Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
|
||||
|
||||
Passing `disabled: true` to `register` (or to `useController`/`Controller`) tells RHF the field is "not part of submission": `handleSubmit` deletes the field from the values object it hands your handler, and validation for that field is skipped. 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.
|
||||
|
||||
The value itself is **not** destroyed — `handleSubmit` unsets disabled names from a *clone* of the form values, so `getValues('promoCode')` still returns what the user typed and re-enabling the field brings it back into the payload. The bug this causes is a field that quietly vanishes from your submit handler while the UI still shows a value in it.
|
||||
|
||||
**Incorrect (using register's disabled option for visual disabling — promoCode silently disappears from the submitted payload):**
|
||||
|
||||
```typescript
|
||||
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, submitCheckout receives no promoCode key at all
|
||||
// and its validation is skipped — while the input still shows the typed value.
|
||||
/>
|
||||
<input {...register('giftCardCode', { disabled: !usingGiftCard })} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use HTML disabled for visual-only disable; use register's disabled only when intentionally excluding the field):**
|
||||
|
||||
```typescript
|
||||
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 `disabled` attribute directly on the input
|
||||
- Want the field excluded from submission and validation → use `register('name', { disabled: true })`
|
||||
|
||||
If a value is disappearing from your submit handler, check for this option before suspecting `getValues()` — the two disagree by design.
|
||||
|
||||
Reference: [register - disabled](https://react-hook-form.com/docs/useform/register)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Keep Default reValidateMode Unless Validation Is Expensive
|
||||
impact: MEDIUM
|
||||
impactDescription: maintains immediate corrective feedback after first submit
|
||||
tags: formcfg, revalidate-mode, validation, useForm
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
|
||||
impact: HIGH
|
||||
impactDescription: prevents silently dropping values the user already entered
|
||||
tags: formcfg, should-unregister, dynamic-forms, conditional-fields
|
||||
---
|
||||
|
||||
## Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
|
||||
|
||||
The default (`shouldUnregister: false`) keeps a field's value in form state after its input unmounts. That is the right default and it is not a memory problem — the retained data is a few strings per field. `shouldUnregister: true` is a **submission-shape** option: it removes unmounted fields from the values object entirely. Reaching for it to "clean up" a multi-step wizard silently deletes everything the user entered on step 1 the moment they advance to step 2.
|
||||
|
||||
**Incorrect (wizard loses step 1 the moment step 2 renders):**
|
||||
|
||||
```typescript
|
||||
function OnboardingWizard() {
|
||||
const [step, setStep] = useState(1)
|
||||
const { register, handleSubmit } = useForm<OnboardingData>({
|
||||
shouldUnregister: true, // personalName is dropped as soon as step 1 unmounts
|
||||
defaultValues: { personalName: '', companyName: '' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(completeOnboarding)}>
|
||||
{step === 1 && <input {...register('personalName')} />}
|
||||
{step === 2 && <input {...register('companyName')} />}
|
||||
<button type="button" onClick={() => setStep(2)}>Next</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (default retention — every step survives to submit):**
|
||||
|
||||
```typescript
|
||||
function OnboardingWizard() {
|
||||
const [step, setStep] = useState(1)
|
||||
const { register, handleSubmit } = useForm<OnboardingData>({
|
||||
defaultValues: { personalName: '', companyName: '' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(completeOnboarding)}>
|
||||
{step === 1 && <input {...register('personalName')} />}
|
||||
{step === 2 && <input {...register('companyName')} />}
|
||||
<button type="button" onClick={() => setStep(2)}>Next</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**When `shouldUnregister: true` is the right call:**
|
||||
- A discriminated payload where the hidden branch's keys must be absent, not empty — e.g. a "Business" account sends `taxId` and a "Personal" one must not send the key at all
|
||||
- A backend that treats a present-but-empty key differently from an absent one
|
||||
- Set it per field via `register('taxId', { shouldUnregister: true })` rather than form-wide, so the rest of the form keeps the safe default
|
||||
|
||||
Reference: [useForm - shouldUnregister](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Pass the Third useForm Generic When the Resolver Transforms Values
|
||||
impact: CRITICAL
|
||||
impactDescription: makes handleSubmit receive the schema's output type instead of its input type
|
||||
tags: formcfg, generics, resolver, zod, transform, typescript
|
||||
---
|
||||
|
||||
## Pass the Third useForm Generic When the Resolver Transforms Values
|
||||
|
||||
`useForm` takes three generics: `useForm<TFieldValues, TContext, TTransformedValues>`. The first is what lives in the form (what inputs produce, before validation); the third is what `handleSubmit` hands your success callback. They are the same type only when the schema does no transformation.
|
||||
|
||||
The moment a schema uses `z.coerce`, `.transform()`, or a `.default()`, input and output diverge — the form holds a string, the schema yields a `Date` or a `number`. Omit the third generic and TypeScript pins the output to the input type, which the resolver then contradicts. With `@hookform/resolvers` v5 the error lands on the `resolver:` property and reads like this:
|
||||
|
||||
```text
|
||||
Type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: Date; … }>' is not assignable to
|
||||
type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: string; … }>'.
|
||||
Types of property 'arrivesOn' are incompatible.
|
||||
Type 'Date' is not assignable to type 'string'.
|
||||
```
|
||||
|
||||
Nothing in that message mentions a missing generic, so the usual reactions are to cast the resolver, widen the schema until the transform is gone, or drop the resolver's type entirely — all of which trade a correct error for silently wrong types. This is the most common typing failure in RHF + Zod and the fix is one type argument.
|
||||
|
||||
**Incorrect (one generic — the resolver's output type contradicts the form's, and handleSubmit is typed with the pre-validation input):**
|
||||
|
||||
```typescript
|
||||
const bookingSchema = z.object({
|
||||
guests: z.coerce.number().int().min(1),
|
||||
arrivesOn: z.iso.date().transform((value) => new Date(value)),
|
||||
})
|
||||
|
||||
type BookingInput = z.input<typeof bookingSchema>
|
||||
|
||||
function BookingForm() {
|
||||
const { register, handleSubmit } = useForm<BookingInput>({
|
||||
resolver: zodResolver(bookingSchema),
|
||||
defaultValues: { guests: '1', arrivesOn: '' },
|
||||
})
|
||||
|
||||
// The resolver above fails to typecheck; values.arrivesOn is string, but is a Date at runtime
|
||||
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (three generics — the callback is typed with the schema's output):**
|
||||
|
||||
```typescript
|
||||
const bookingSchema = z.object({
|
||||
guests: z.coerce.number().int().min(1),
|
||||
arrivesOn: z.iso.date().transform((value) => new Date(value)),
|
||||
})
|
||||
|
||||
type BookingInput = z.input<typeof bookingSchema>
|
||||
type BookingOutput = z.output<typeof bookingSchema>
|
||||
|
||||
function BookingForm() {
|
||||
const { register, handleSubmit } = useForm<BookingInput, unknown, BookingOutput>({
|
||||
resolver: zodResolver(bookingSchema),
|
||||
defaultValues: { guests: '1', arrivesOn: '' },
|
||||
})
|
||||
|
||||
// values.arrivesOn is Date, values.guests is number — matching runtime
|
||||
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
|
||||
}
|
||||
```
|
||||
|
||||
The middle generic is the resolver context; pass `unknown` when you don't use one. Derive both types from the schema (`z.input` / `z.output`) rather than hand-writing them, so they can't drift.
|
||||
|
||||
If `handleSubmit` is fighting you about a field type, check this generic before reaching for `as`.
|
||||
|
||||
Reference: [useForm](https://react-hook-form.com/docs/useform) · [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Depend on formState Slices, Not on formState Itself
|
||||
impact: HIGH
|
||||
impactDescription: prevents effects that re-run on every keystroke
|
||||
tags: formcfg, useEffect, dependencies, formState
|
||||
---
|
||||
|
||||
## Depend on formState Slices, Not on formState Itself
|
||||
|
||||
The `useForm()` return object is **stable**. `useForm` keeps it in a `useRef` and returns the same object on every render, mutating `formState` onto it — so `useEffect(fn, [form])` runs once, and `register`, `reset`, `control`, `setValue` and friends are all safe dependencies. (Widely repeated advice says the form object is a fresh reference each render and loops; that is not true of any v7 release.)
|
||||
|
||||
The `formState` **proxy** is the part that changes. It is rebuilt via `useMemo` keyed on the underlying state, so it gets a new identity on every form-state update — every keystroke, in `onChange` mode. Depending on it re-runs the effect that often. Depend on the specific boolean you care about.
|
||||
|
||||
**Incorrect (effect re-runs on every form-state update, not just on success):**
|
||||
|
||||
```typescript
|
||||
function ContactForm({ onSaved }: { onSaved: () => void }) {
|
||||
const { register, handleSubmit, reset, formState } = useForm({
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.isSubmitSuccessful) {
|
||||
reset()
|
||||
onSaved()
|
||||
}
|
||||
}, [formState, reset, onSaved]) // New proxy identity on every keystroke
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveContact)}>
|
||||
<input {...register('email')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (depend on the slice that actually gates the effect):**
|
||||
|
||||
```typescript
|
||||
function ContactForm({ onSaved }: { onSaved: () => void }) {
|
||||
const { register, handleSubmit, reset, formState: { isSubmitSuccessful } } = useForm({
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitSuccessful) {
|
||||
reset()
|
||||
onSaved()
|
||||
}
|
||||
}, [isSubmitSuccessful, reset, onSaved]) // Only flips once per successful submit
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveContact)}>
|
||||
<input {...register('email')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Destructuring also matters for a second reason: reading `formState.isSubmitSuccessful` is what registers the Proxy subscription in the first place — see `formstate-destructure-formstate`.
|
||||
|
||||
Reference: [useForm](https://react-hook-form.com/docs/useform) · [formState](https://react-hook-form.com/docs/useform/formstate)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Justify Any mode Other Than the Default onSubmit
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents a full validation pass and re-render on every keystroke
|
||||
tags: formcfg, validation-mode, re-renders, useForm
|
||||
---
|
||||
|
||||
## Justify Any mode Other Than the Default onSubmit
|
||||
|
||||
`mode` decides when RHF validates. The default is `'onSubmit'`, and you should have to argue your way off it: `'onChange'` runs the field's validation — the whole resolver schema, if you use one — and re-renders on every keystroke. It gets reached for reflexively because "validate as they type" sounds like better UX, when in practice it means showing someone an "invalid email" error while they are still on the third character.
|
||||
|
||||
**Incorrect (onChange chosen by default — errors fire mid-word, every keystroke re-validates):**
|
||||
|
||||
```typescript
|
||||
function RegistrationForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<RegistrationData>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createAccount)}>
|
||||
<input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' } })} />
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (leave the default; escalate only where it earns its keep):**
|
||||
|
||||
```typescript
|
||||
function RegistrationForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<RegistrationData>({
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createAccount)}>
|
||||
<input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' } })} />
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`reValidateMode` already defaults to `'onChange'`, so a field that has *failed* validation does give immediate feedback as the user corrects it — which is what people usually think they need `mode: 'onChange'` for.
|
||||
|
||||
**Modes worth the escalation:**
|
||||
- `onTouched` — validate after the first blur, then on change. The usual right answer when submit-time errors feel too late.
|
||||
- `onBlur` — validate on blur only; quieter than `onTouched` while correcting.
|
||||
- `onChange` — password-strength meters, "username is available" checks, live-computed totals. Add a comment saying which.
|
||||
- `all` — `onBlur` and `onChange` together; rarely justified.
|
||||
|
||||
Reference: [useForm - mode](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Use the values Prop to Keep a Form in Sync with Server Data
|
||||
impact: HIGH
|
||||
impactDescription: replaces a useEffect+reset that overwrites edits whenever the query refetches
|
||||
tags: formcfg, values, resetOptions, react-query, server-state
|
||||
---
|
||||
|
||||
## Use the values Prop to Keep a Form in Sync with Server Data
|
||||
|
||||
When the initial data arrives from a query, the reflex is `useEffect(() => reset(data), [data])`. That works until the query refetches — on window focus, on interval, after an unrelated mutation — and the effect fires again mid-edit, wiping whatever the user had typed.
|
||||
|
||||
`useForm({ values })` is the built-in answer. RHF re-syncs the form when the `values` reference changes, and `resetOptions: { keepDirtyValues: true }` tells it to leave fields the user has touched alone while updating the ones they haven't. `defaultValues` still supplies the shape before the first response lands.
|
||||
|
||||
**Incorrect (effect-driven reset — a background refetch discards in-progress edits):**
|
||||
|
||||
```typescript
|
||||
function ProfileForm({ userId }: { userId: string }) {
|
||||
const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile })
|
||||
const { register, reset, handleSubmit } = useForm<ProfileFormValues>({
|
||||
defaultValues: { displayName: '', bio: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) reset(profile) // Fires again on every refetch, mid-edit
|
||||
}, [profile, reset])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveProfile)}>
|
||||
<input {...register('displayName')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (declarative sync that preserves what the user has touched):**
|
||||
|
||||
```typescript
|
||||
function ProfileForm({ userId }: { userId: string }) {
|
||||
const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile })
|
||||
const { register, handleSubmit } = useForm<ProfileFormValues>({
|
||||
defaultValues: { displayName: '', bio: '' },
|
||||
values: profile,
|
||||
resetOptions: { keepDirtyValues: true },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveProfile)}>
|
||||
<input {...register('displayName')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Which initialiser to reach for:**
|
||||
- `defaultValues` — the shape and the baseline; required regardless (see `formcfg-default-values`)
|
||||
- `values` — the record is fetched and may change while the form is open
|
||||
- async `defaultValues` — the record is fetched once and will not change under the form (see `formcfg-async-default-values`)
|
||||
|
||||
Drop `keepDirtyValues` only when a server change should win over the user's edit — a record another person may be editing concurrently, for instance.
|
||||
|
||||
Reference: [useForm - values](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
|
||||
impact: HIGH
|
||||
impactDescription: prevents stuck isSubmitting state and missing post-success reset
|
||||
tags: formstate, isSubmitting, isSubmitSuccessful, async, submit, reset
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
- `isSubmitting` resets only when the handler **returns** (resolves). A throw leaves it `true` and the form unrecoverable
|
||||
- `isSubmitSuccessful` becomes `true` when the handler completes without throwing and without calling `setError`. Use it to gate the post-success reset
|
||||
- Calling `reset()` inside the submit handler races with React's commit of `isSubmitSuccessful`; the `useEffect` form is the documented pattern
|
||||
- If you want to preserve specific fields across reset, pass them: `reset(undefined, { keepDirtyValues: true })` or `reset({ defaultValue: lastSaved })`
|
||||
|
||||
Reference: [formState](https://react-hook-form.com/docs/useform/formstate) · [reset](https://react-hook-form.com/docs/useform/reset) · [Discussion #10103 — isSubmitting does not recover when submit handler throws](https://github.com/orgs/react-hook-form/discussions/10103)
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Avoid isValid with onSubmit Mode for Button State
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents whole-form validation on every change under a deferred-validation mode
|
||||
tags: formstate, isValid, onSubmit, validation-mode
|
||||
---
|
||||
|
||||
## Avoid isValid with onSubmit Mode for Button State
|
||||
|
||||
Subscribing to `isValid` opts the form into continuous validation. RHF only computes it when something reads it — `_setValid()` is gated on `isValid` being subscribed — but once it is, RHF runs the **whole form's** validation (the entire resolver schema, not just the changed field) on mount and again on every change event. Choosing `mode: 'onSubmit'` to defer validation and then reading `isValid` to grey out the submit button cancels out the deferral you asked for.
|
||||
|
||||
(The cost is per change event, not per render: re-rendering the component without touching a field does not re-validate.)
|
||||
|
||||
**Incorrect (isValid re-validates the whole form on every change despite onSubmit mode):**
|
||||
|
||||
```typescript
|
||||
function RegistrationForm() {
|
||||
const { register, handleSubmit, formState: { isValid } } = useForm<RegistrationData>({
|
||||
defaultValues: { email: '', password: '' }, // mode defaults to 'onSubmit'
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('email', { required: true })} />
|
||||
<input {...register('password', { required: true })} />
|
||||
<button disabled={!isValid}>Register</button> {/* Opts the form into validating on every change */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use isSubmitting or allow submit attempt):**
|
||||
|
||||
```typescript
|
||||
function RegistrationForm() {
|
||||
const { register, handleSubmit, formState: { isSubmitting } } = useForm<RegistrationData>({
|
||||
defaultValues: { email: '', password: '' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('email', { required: true })} />
|
||||
<input {...register('password', { required: true })} />
|
||||
<button disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative:** if a live-disabled submit button really is the requirement, say so explicitly rather than leaving the two settings in tension — pair `isValid` with a mode that already validates continuously:
|
||||
|
||||
```typescript
|
||||
function RegistrationForm() {
|
||||
const { register, formState: { isValid } } = useForm<RegistrationData>({
|
||||
mode: 'onChange', // Deliberate: the button reflects validity as the user types
|
||||
defaultValues: { email: '', password: '' },
|
||||
})
|
||||
|
||||
return <button disabled={!isValid}>Register</button>
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [useForm - mode](https://react-hook-form.com/docs/useform)
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Read Every formState Property You Depend On During Render
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents a component that never re-renders when the state it shows changes
|
||||
tags: formstate, formState, proxy, subscription, conditional
|
||||
---
|
||||
|
||||
## Read Every formState Property You Depend On During Render
|
||||
|
||||
`formState` is a Proxy: each property has a getter that marks it subscribed the first time it is read. Subscription is established by the *read*, not by how you write it — `formState.isValid`, `const { isValid } = formState`, and destructuring in the `useForm` call are all equivalent, and all three subscribe to exactly `isValid`. (The common claim that touching the whole object "disables the optimization" is not true; the getters are per-property.)
|
||||
|
||||
The real trap is a property that is never read during render. Read it only inside a callback, or only in a branch that doesn't run on the first render, and the getter never fires — so RHF never re-renders the component when that property changes, and the UI silently stops updating.
|
||||
|
||||
**Incorrect (isSubmitting is only read inside the handler — the button label never updates):**
|
||||
|
||||
```typescript
|
||||
function SaveButton() {
|
||||
const { handleSubmit, formState } = useForm<ArticleDraft>({ defaultValues: emptyDraft })
|
||||
|
||||
const onClick = handleSubmit(async (values) => {
|
||||
if (formState.isSubmitting) return // First read happens in a callback, after render
|
||||
await saveDraft(values)
|
||||
})
|
||||
|
||||
return <button onClick={onClick}>Save</button>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (read it in the render body, so the subscription exists):**
|
||||
|
||||
```typescript
|
||||
function SaveButton() {
|
||||
const { handleSubmit, formState: { isSubmitting } } = useForm<ArticleDraft>({ defaultValues: emptyDraft })
|
||||
|
||||
const onClick = handleSubmit(async (values) => {
|
||||
await saveDraft(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<button onClick={onClick} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same applies to a conditional read: `{step === 2 && errors.email && …}` does not subscribe to `errors` until `step` reaches 2. Destructuring at the top of the component is the habit that makes this a non-issue, which is the real reason to do it.
|
||||
|
||||
Reference: [formState](https://react-hook-form.com/docs/useform/formstate) · [useFormState](https://react-hook-form.com/docs/useformstate)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Use handleSubmit's Second Argument to Handle a Rejected Submit
|
||||
impact: MEDIUM
|
||||
impactDescription: gives a failed submit somewhere to go instead of silently doing nothing
|
||||
tags: formstate, handleSubmit, onInvalid, errors, accessibility
|
||||
---
|
||||
|
||||
## Use handleSubmit's Second Argument to Handle a Rejected Submit
|
||||
|
||||
`handleSubmit(onValid, onInvalid)` takes two callbacks. Almost all code passes only the first, so when validation fails the click does nothing observable: no navigation, no request, and — on a long form — an error message somewhere below the fold that the user never scrolls to. They press the button again, harder.
|
||||
|
||||
`onInvalid` receives the same `FieldErrors` object as `formState.errors` and is the natural place to move focus to the first bad field, scroll it into view, announce it, or record that submission is failing.
|
||||
|
||||
**Incorrect (invalid submit is a no-op from the user's point of view):**
|
||||
|
||||
```typescript
|
||||
function ApplicationForm() {
|
||||
const { register, handleSubmit } = useForm<ApplicationValues>({
|
||||
defaultValues: emptyApplication,
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submitApplication)}>
|
||||
{/* 40 fields; the invalid one may be far off-screen */}
|
||||
<button type="submit">Submit application</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (failed validation moves the user to the problem):**
|
||||
|
||||
```typescript
|
||||
function ApplicationForm() {
|
||||
const { register, handleSubmit, setFocus } = useForm<ApplicationValues>({
|
||||
defaultValues: emptyApplication,
|
||||
})
|
||||
|
||||
const onInvalid = (errors: FieldErrors<ApplicationValues>) => {
|
||||
const firstField = Object.keys(errors)[0] as FieldPath<ApplicationValues> | undefined
|
||||
if (firstField) setFocus(firstField, { shouldSelect: true })
|
||||
trackEvent('application_submit_rejected', { fieldCount: Object.keys(errors).length })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submitApplication, onInvalid)}>
|
||||
<button type="submit">Submit application</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Note the two callbacks are typed differently: `onValid` receives the schema's **output** type (see `formcfg-transformed-values-generic`), while `onInvalid` receives errors keyed on the form's input type.
|
||||
|
||||
RHF also focuses the first errored field itself when the field was registered with a ref — `onInvalid` is what you need when the control is custom, virtualized, or on another wizard step, where there is no ref to focus.
|
||||
|
||||
Reference: [handleSubmit](https://react-hook-form.com/docs/useform/handlesubmit)
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Rebase Defaults with resetDefaultValues After a Successful Save
|
||||
impact: HIGH
|
||||
impactDescription: clears isDirty without discarding edits made during the in-flight request
|
||||
tags: formstate, resetDefaultValues, isDirty, dirtyFields, save
|
||||
---
|
||||
|
||||
## Rebase Defaults with resetDefaultValues After a Successful Save
|
||||
|
||||
For a form that stays mounted after saving (settings pages, inline editors), the goal after a successful `PATCH` is to make `isDirty` false again — the saved values are the new baseline. The reflex is `reset(savedValues)`, but `reset` writes **both** `defaultValues` and the live form values. Any keystroke the user made while the request was in flight is silently thrown away, and controlled inputs re-mount.
|
||||
|
||||
`resetDefaultValues(savedValues)` (RHF 7.77+, also exposed on `useFormContext` since 7.82) replaces `defaultValues` and recomputes `dirtyFields`/`isDirty` against the values already in the form, without touching them. Edits made during the save stay put and are correctly reported as dirty.
|
||||
|
||||
**Incorrect (reset discards edits made while the save was in flight):**
|
||||
|
||||
```typescript
|
||||
function NotificationSettingsForm({ settings }: { settings: NotificationSettings }) {
|
||||
const { register, handleSubmit, reset, formState: { isDirty, isSubmitting } } =
|
||||
useForm({ defaultValues: settings })
|
||||
|
||||
const onSubmit = async (values: NotificationSettings) => {
|
||||
try {
|
||||
const saved = await updateNotificationSettings(values)
|
||||
reset(saved) // Overwrites live values — anything typed during the request is lost
|
||||
} catch {
|
||||
setError('root.serverError', { message: 'Could not save settings' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('digestEmail')} />
|
||||
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (rebase the baseline, keep the user's in-flight edits):**
|
||||
|
||||
```typescript
|
||||
function NotificationSettingsForm({ settings }: { settings: NotificationSettings }) {
|
||||
const { register, handleSubmit, resetDefaultValues, setError, formState: { isDirty, isSubmitting } } =
|
||||
useForm({ defaultValues: settings })
|
||||
|
||||
const onSubmit = async (values: NotificationSettings) => {
|
||||
try {
|
||||
const saved = await updateNotificationSettings(values)
|
||||
resetDefaultValues(saved) // New baseline; live values untouched, isDirty recomputed against them
|
||||
} catch {
|
||||
setError('root.serverError', { message: 'Could not save settings' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('digestEmail')} />
|
||||
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Which to reach for:**
|
||||
- `resetDefaultValues(saved)` — form stays mounted and the user keeps editing; you only want the dirty baseline moved
|
||||
- `reset(saved)` — you genuinely want to discard the current values too (form closes, or you are loading a different record)
|
||||
|
||||
`resetDefaultValues` accepts `{ keepDirty }` and `{ keepIsValid }` if you need to suppress either recomputation.
|
||||
|
||||
Reference: [useForm - resetDefaultValues](https://react-hook-form.com/docs/useform/resetdefaultvalues)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveIndicator({ control }: { control: Control }) {
|
||||
const { isDirty } = useFormState({ control })
|
||||
|
||||
return isDirty ? <span>Unsaved changes</span> : null
|
||||
}
|
||||
```
|
||||
|
||||
**Scope it further with `name`.** An isolated `useFormState({ control })` still re-renders on *any* field's state change. Pass `name` to narrow it to the fields the component actually displays:
|
||||
|
||||
```typescript
|
||||
function EmailFieldError({ control }: { control: Control<SignupFormValues> }) {
|
||||
const { errors } = useFormState({ control, name: 'email' })
|
||||
return errors.email ? <span>{errors.email.message}</span> : null
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [useFormState](https://react-hook-form.com/docs/useformstate)
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Wire shadcn Select with onValueChange Instead of Spread
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents a Radix Select that renders but never writes to the form
|
||||
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)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Transform Values at Controller Level for Type Coercion
|
||||
impact: MEDIUM
|
||||
impactDescription: stops string input values reaching a number- or date-typed schema
|
||||
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)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
|
||||
impact: MEDIUM
|
||||
impactDescription: replaces a memo pass that has no effect with isolation that does
|
||||
tags: sub, FormProvider, memo, useFormContext, isolation
|
||||
---
|
||||
|
||||
## React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
|
||||
|
||||
The instinct when a `FormProvider` tree re-renders too much is to wrap the heavy children in `React.memo`. It does nothing here. `React.memo` compares props; a component that calls `useFormContext()` is a context consumer, and a context value change re-renders it regardless of whether its props were equal. `FormProvider` already memoizes its own value, but `formState` is one of that memo's dependencies, so the value's identity does change on every form-state update — and every `useFormContext()` consumer under it re-renders.
|
||||
|
||||
Wrapping in `memo` therefore buys nothing for the components you were worried about, and costs a comparison on every render for the ones you weren't.
|
||||
|
||||
**Incorrect (memo on a context consumer — still re-renders on every form-state update):**
|
||||
|
||||
```typescript
|
||||
const AddressSection = React.memo(function AddressSection() {
|
||||
const { register, control } = useFormContext<CheckoutForm>()
|
||||
const { errors } = useFormState<CheckoutForm>({ control }) // Consumes context; memo is bypassed
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<input {...register('street')} />
|
||||
{errors.street && <span>{errors.street.message}</span>}
|
||||
</fieldset>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (subscribe to the narrowest slice, so the re-render is cheap and local):**
|
||||
|
||||
```typescript
|
||||
function AddressSection() {
|
||||
const { register, control } = useFormContext<CheckoutForm>()
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<input {...register('street')} />
|
||||
<FormStateSubscribe
|
||||
control={control}
|
||||
name="street"
|
||||
render={({ errors }) => (errors.street ? <span>{errors.street.message}</span> : null)}
|
||||
/>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`React.memo` is still worth reaching for on a genuinely expensive child that takes plain props and does **not** read form context — a chart, a map, a large static list rendered as a sibling of the form. The distinction is whether the component consumes context at all.
|
||||
|
||||
Reference: [FormProvider](https://react-hook-form.com/docs/formprovider) · [useFormState](https://react-hook-form.com/docs/useformstate)
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Use the Render-Prop Components to Isolate Re-renders Without a Child Component
|
||||
impact: HIGH
|
||||
impactDescription: confines a subscription to one subtree without authoring a wrapper component
|
||||
tags: sub, Watch, FormStateSubscribe, FieldArray, isolation, render-prop
|
||||
---
|
||||
|
||||
## Use the Render-Prop Components to Isolate Re-renders Without a Child Component
|
||||
|
||||
Re-render isolation comes from putting the subscription somewhere other than the form component. The usual advice — extract a child component — works but costs a component and a prop-drilled `control` for every watched value. React Hook Form ships render-prop wrappers that do the same thing inline: `<Watch>` wraps `useWatch`, `<FormStateSubscribe>` wraps `useFormState`, and `<FieldArray>` (7.81+) wraps `useFieldArray`. Each is a one-line component that calls the hook and hands the result to `render`, so the subscription lives in that element and only that subtree re-renders.
|
||||
|
||||
**Incorrect (hooks in the form component — every keystroke re-renders the whole form):**
|
||||
|
||||
```typescript
|
||||
function InvoiceForm() {
|
||||
const { control, register, handleSubmit } = useForm<Invoice>({ defaultValues: emptyInvoice })
|
||||
const { fields, append } = useFieldArray({ control, name: 'lineItems' })
|
||||
const [quantity, unitPrice] = useWatch({ control, name: ['quantity', 'unitPrice'] })
|
||||
const { isDirty, isSubmitting } = useFormState({ control })
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveInvoice)}>
|
||||
{fields.map((field, index) => (
|
||||
<input key={field.id} {...register(`lineItems.${index}.description`)} />
|
||||
))}
|
||||
<button type="button" onClick={() => append({ description: '' })}>Add line</button>
|
||||
<p>Total: {quantity * unitPrice}</p>
|
||||
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (each subscription confined to its own element):**
|
||||
|
||||
```typescript
|
||||
function InvoiceForm() {
|
||||
const { control, register, handleSubmit } = useForm<Invoice>({ defaultValues: emptyInvoice })
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveInvoice)}>
|
||||
<FieldArray
|
||||
control={control}
|
||||
name="lineItems"
|
||||
render={({ fields, append }) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<input key={field.id} {...register(`lineItems.${index}.description`)} />
|
||||
))}
|
||||
<button type="button" onClick={() => append({ description: '' })}>Add line</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Watch
|
||||
control={control}
|
||||
name={['quantity', 'unitPrice']}
|
||||
render={([quantity, unitPrice]) => <p>Total: {quantity * unitPrice}</p>}
|
||||
/>
|
||||
<FormStateSubscribe
|
||||
control={control}
|
||||
render={({ isDirty, isSubmitting }) => (
|
||||
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Two traps in the current typings:**
|
||||
- `<Watch>` accepts both `name` and `names`. `names` is marked `@deprecated` in 7.82 and is renamed away in v8 — write `name`, even though the shipped JSDoc example still shows `names`.
|
||||
- `FieldArrayProps.render` is typed to return `React.ReactElement`, not `ReactNode[]`. Returning `fields.map(...)` directly fails to typecheck despite appearing that way in the shipped JSDoc — wrap the output in a fragment.
|
||||
|
||||
Prefer an extracted child component when the subtree needs its own logic, handlers, or memoization; prefer the render-prop component when it is purely "read this value, render this markup".
|
||||
|
||||
Reference: [useWatch](https://react-hook-form.com/docs/usewatch) · [useFieldArray](https://react-hook-form.com/docs/usefieldarray)
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Use subscribe() to React to Form Changes Outside the React Lifecycle
|
||||
impact: HIGH
|
||||
impactDescription: eliminates re-renders for non-UI consumers like analytics, autosave, telemetry
|
||||
tags: sub, subscribe, side-effects, analytics, autosave, useForm
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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 element
|
||||
- `subscribe` — the value drives a non-UI side-effect (analytics, autosave, localStorage sync, telemetry)
|
||||
- `watch(callback)` — legacy callback form; prefer `subscribe` in 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](https://react-hook-form.com/docs/useform/subscribe) · [Release notes v7.55.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.55.0)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Use useWatch Instead of watch for Isolated Re-renders
|
||||
impact: CRITICAL
|
||||
impactDescription: confines value-change re-renders to the subscribing component
|
||||
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} />
|
||||
}
|
||||
```
|
||||
|
||||
**Push the subscription as deep as it will go.** The win is not `useWatch` over `watch` in itself — it is *where the subscription lives*. A `useWatch` at the top of the form re-renders the whole form exactly like `watch` does. Put it in the leaf that renders the value, and pass `control` down rather than the watched value; the sibling sections then never re-render. If you don't want to author a component for it, `<Watch>` does the same inline — see `sub-render-prop-components`.
|
||||
|
||||
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
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
|
||||
|
||||
**Opting a single `setValue` into the delay (7.82+):** `setValue` accepts `delayError`, but it is a **boolean** — it opts that call into the debounce, and the duration still comes from `useForm({ delayError })`. Passing a number is a type error, even though the 7.82.0 release notes show `delayError: 500`:
|
||||
|
||||
```typescript
|
||||
const { setValue } = useForm({ mode: 'onChange', delayError: 300 })
|
||||
|
||||
setValue('query', suggestion, { shouldValidate: true, delayError: true }) // debounced by 300ms
|
||||
```
|
||||
|
||||
Without a form-level `delayError`, `{ delayError: true }` on `setValue` has nothing to debounce with and the error appears immediately.
|
||||
|
||||
Reference: [useForm - delayError](https://react-hook-form.com/docs/useform) · [useForm - setValue](https://react-hook-form.com/docs/useform/setvalue)
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Build the Validation Schema Once, Outside the Render Path
|
||||
impact: HIGH
|
||||
impactDescription: stops rebuilding the whole schema object on every keystroke
|
||||
tags: valid, resolver, schema, zod, useMemo
|
||||
---
|
||||
|
||||
## Build the Validation Schema Once, Outside the Render Path
|
||||
|
||||
There is no resolver cache in React Hook Form — `useForm` reassigns `control._options = props` on every render, and whatever resolver you passed is used as-is. The cost of an inline schema is the **construction**: `z.object({ … })` allocates a fresh validator tree every render, and under `mode: 'onChange'` that is once per keystroke, on top of the validation itself. Hoisting the schema to module scope makes it a one-time cost at import.
|
||||
|
||||
**Incorrect (a new schema object built on every render):**
|
||||
|
||||
```typescript
|
||||
function InviteMemberForm() {
|
||||
const { register, handleSubmit } = useForm<InviteFormValues>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
email: z.email('Enter a valid email address'),
|
||||
role: z.enum(['admin', 'editor', 'viewer']),
|
||||
}),
|
||||
),
|
||||
defaultValues: { email: '', role: 'viewer' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(sendInvite)}>
|
||||
<input {...register('email')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (built once at module load):**
|
||||
|
||||
```typescript
|
||||
const inviteSchema = z.object({
|
||||
email: z.email('Enter a valid email address'),
|
||||
role: z.enum(['admin', 'editor', 'viewer']),
|
||||
})
|
||||
|
||||
function InviteMemberForm() {
|
||||
const { register, handleSubmit } = useForm<InviteFormValues>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: { email: '', role: 'viewer' },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(sendInvite)}>
|
||||
<input {...register('email')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**When the schema genuinely depends on props or context**, hoist a factory instead of the schema and memoize the call — so it rebuilds when the input changes, not when the component renders:
|
||||
|
||||
```typescript
|
||||
const createSeatSchema = (maxSeats: number) =>
|
||||
z.object({
|
||||
seats: z.number().int().max(maxSeats, `Your plan allows ${maxSeats} seats`),
|
||||
})
|
||||
|
||||
function SeatAllocationForm({ maxSeats }: { maxSeats: number }) {
|
||||
const schema = useMemo(() => createSeatSchema(maxSeats), [maxSeats])
|
||||
|
||||
const { register, handleSubmit } = useForm<SeatFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { seats: 1 },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(updateSeats)}>
|
||||
<input type="number" {...register('seats', { valueAsNumber: true })} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Prefer a schema-level `.refine()` over a factory when the rule depends on *other fields* rather than on props — cross-field rules don't need the schema rebuilt.
|
||||
|
||||
Reference: [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Surface Server Errors via setError('root.serverError', ...)
|
||||
impact: HIGH
|
||||
impactDescription: prevents lost server-side validation errors and unrecoverable form state
|
||||
tags: valid, server-errors, setError, async, error-handling
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
```typescript
|
||||
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):**
|
||||
|
||||
```typescript
|
||||
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/catch` async submit handlers. `handleSubmit` will not surface thrown errors, and `isSubmitting` only resets when the handler returns (resolves), not when it throws — see also `formstate-async-submit-lifecycle`
|
||||
|
||||
Reference: [setError](https://react-hook-form.com/docs/useform/seterror) · [Discussion #9691 — Handle global/server errors](https://github.com/orgs/react-hook-form/discussions/9691)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Handle the NaN valueAsNumber Produces for an Empty Input
|
||||
impact: HIGH
|
||||
impactDescription: prevents an optional number field that can never be left blank
|
||||
tags: valid, valueAsNumber, setValueAs, NaN, optional-fields
|
||||
---
|
||||
|
||||
## Handle the NaN valueAsNumber Produces for an Empty Input
|
||||
|
||||
`register('n', { valueAsNumber: true })` converts an empty string to **`NaN`**, not to `undefined` or `null`. This is deliberate — a fix that treated `NaN` as empty was reverted in 7.76.1 — so it is stable behaviour you have to design around rather than a bug to wait out.
|
||||
|
||||
It is invisible on a required field, where any complaint is the complaint you wanted. It breaks **optional** number fields: the user clears the box, the schema receives `NaN`, `z.number().optional()` rejects it, and the field can never be left blank. The error text ("expected number, received nan") points at the schema rather than at the conversion, so the cause is easy to miss.
|
||||
|
||||
**Incorrect (an optional field the user cannot clear):**
|
||||
|
||||
```typescript
|
||||
const listingSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
reservePrice: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
function ListingForm() {
|
||||
const { register, handleSubmit } = useForm<ListingFormValues>({
|
||||
resolver: zodResolver(listingSchema),
|
||||
defaultValues: { title: '', reservePrice: undefined },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveListing)}>
|
||||
{/* Clearing the input yields NaN, which fails .optional() */}
|
||||
<input type="number" {...register('reservePrice', { valueAsNumber: true })} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (map empty to undefined with setValueAs):**
|
||||
|
||||
```typescript
|
||||
const listingSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
reservePrice: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
function ListingForm() {
|
||||
const { register, handleSubmit } = useForm<ListingFormValues>({
|
||||
resolver: zodResolver(listingSchema),
|
||||
defaultValues: { title: '', reservePrice: undefined },
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(saveListing)}>
|
||||
<input
|
||||
type="number"
|
||||
{...register('reservePrice', {
|
||||
setValueAs: (value) => (value === '' ? undefined : Number(value)),
|
||||
})}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`setValueAs` and `valueAsNumber` are mutually exclusive — supplying `setValueAs` replaces the built-in conversion, which is exactly what you want here.
|
||||
|
||||
For a **required** number, `valueAsNumber: true` is fine: `NaN` fails validation, which is the correct outcome. Reserve `setValueAs` for fields that are genuinely allowed to be empty, and prefer it over `z.coerce.number()`, which turns `''` into `0` and would silently record a reserve price of zero.
|
||||
|
||||
Reference: [register - valueAsNumber](https://react-hook-form.com/docs/useform/register)
|
||||
Reference in New Issue
Block a user