Added AI skills

This commit is contained in:
Jose Selesan
2026-09-04 16:49:24 -03:00
parent 778f3fdcad
commit dfa7f73ebb
368 changed files with 51560 additions and 0 deletions

View 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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)