React Hook Form 21 ๐ Performance Optimization โ Isolating subscriptions and avoiding re-renders
The React Hook Form is fast by default because the uncontrolled inputs keep the typing off the React render path. But the default is not the guarantee. The form re-renders when the component reads the formState properties that change, when the watch call returns the new values, and when the parent re-renders for the unrelated reason. The optimization is the discipline of narrowing the subscription to the specific property that the component actually reads, and the narrowing is the mechanism that the re-render count is the proportional to the actual changes. The three techniques that matter are the narrow formState destructuring, the useWatch scoped to the child component, and the Controller isolated to the field .
The narrow destructuring is the first and the most important. The formState is the proxy, and the component re-renders only when the properties it reads during the render change. A component that destructures the errors re-renders when the errors change, and it does not re-render when the isDirty changes . The developer who destructures the whole formState object subscribes to every property, and the every-property is the reason the form re-renders on every interaction. The narrow destructuring is the mechanism that the subscription is the precise.
The useWatch scoped to the child component is the second. The watch in the parent re-renders the whole form on every change, and the useWatch in the child re-renders only the child . The extraction is the mechanism that the expensive subtree is the isolated. The useWatch with the compute option is the further reductionโthe component re-renders only when the computed result changes, not on every keystroke.
The Controller isolated to the field is the third. The Controller‘s render function re-renders the wrapped component when the field changes, and the parent form does not re-render. The useController hook is the same contract for the reusable component . The isolation is the mechanism that the controlled component does not pull the form into the render.
Key point: The narrow formState destructuring, the useWatch scoped to the child, and the Controller isolated to the field are the three optimization techniques. The formState is the proxy that re-renders only the read properties. The watch in the parent re-renders the whole form, and the useWatch in the child re-renders only the child. The useForm‘s shouldUnregister: false is the default that the multi-step form relies on.
Why the default is not the guarantee
The whole-formState problem. A component that calls const { formState } = useForm() and passes the formState to the child subscribes to every property. The formState is the proxy, and the read of the formState object itself is the subscription to all its properties . The developer who wants the errors reads the formState.errors, and the reading is the precise; the developer who wants the whole object gets the whole subscription. The distinction is the mechanism that the re-render count is the different.
The watch problem. The watch function re-renders the component that calls it. The watch("email") in the parent re-renders the parent on every keystroke in the email field . The useWatch({ control, name: "email" }) in the child re-renders only the child. The extraction is the mechanism that the parent is the untouched, and the untouched is the reason the typing is the fast.
The parent-re-render problem. The form re-renders when the parent re-renders for the unrelated reason. The React.memo on the field components is the mechanism that the unrelated parent re-render does not propagate to the fields . The useCallback on the handlers is the same mechanism, and the memo is the reason the unrelated re-render is the blocked.
The Controller problem. The Controller‘s render function is the mechanism that the wrapped component re-renders when the field changes. The render function is recreated on every parent render unless the Controller is the memoized, and the recreation is the reason the render is the unstable . The useController hook is the stable alternative, and the stable is the reason the reusable component is the fast.
The useFieldArray problem. The fields array is the render metadata, and the fields is the new reference on every array change. The map over the fields re-renders the rows, and the re-render is the necessary because the rows are the changed. The useFieldArray is the fast because the fields is the metadata rather than the values, and the metadata is the reason the rows’ inputs are the uncontrolled.
a. The narrow formState destructuring and the useWatch
The narrow destructuring is the first. The component reads only the errors, and the isDirty and the isSubmitting are the separate components.
// The narrow formState destructuring
import { useForm } from "react-hook-form";
function EmailField() {
const {
register,
// The component reads only the errors.
// The other formState properties are not subscribed.
formState: { errors },
} = useForm<FormValues>();
return (
<div>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
);
}
function SubmitButton() {
const {
// The component reads only the isSubmitting.
// The errors are not subscribed.
formState: { isSubmitting },
} = useForm<FormValues>();
return <button disabled={isSubmitting}>Submit</button>;
}
The formState: { errors } is the mechanism that the EmailField subscribes only to the errors. The formState: { isSubmitting } is the mechanism that the SubmitButton subscribes only to the isSubmitting . The two are the different components, and the different is the reason the error change does not re-render the button.
The useWatch scoped to the child is the second. The watch in the parent re-renders the parent, and the useWatch in the child re-renders only the child.
// The useWatch scoped to the child
import { useForm, useWatch, type Control } from "react-hook-form";
function TotalDisplay({ control }: { control: Control<FormValues> }) {
// The useWatch re-renders only this component.
const items = useWatch({ control, name: "items" });
const total = items.reduce((sum, item) => sum + item.price, 0);
return <p>Total: ${total}</p>;
}
function OrderForm() {
const { register, control, handleSubmit } = useForm<FormValues>();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("coupon")} />
{/* The TotalDisplay re-renders, the OrderForm does not. */}
<TotalDisplay control={control} />
</form>
);
}
The useWatch({ control, name: "items" }) is the mechanism that the TotalDisplay subscribes only to the items. The OrderForm does not read the items, and the OrderForm is the untouched . The compute option is the further reduction: the component re-renders only when the computed result changes.
// The useWatch with the compute
const count = useWatch({
control,
name: "items",
// The component re-renders only when the count changes,
// not on every keystroke in the item name.
compute: (items = []) => items.length,
});
The compute: (items = []) => items.length is the mechanism that the component re-renders only when the count changes. The keystroke in the item name changes the items array, and the array is the new reference, but the length is the same, and the compute is the mechanism that the re-render is the skipped .
b. The Controller isolation and the memoization
The Controller is the isolation mechanism for the controlled component. The render function receives the field and the fieldState, and the wrapped component re-renders when the field changes.
// The Controller isolation
import { useForm, Controller } from "react-hook-form";
function Form() {
const { control, handleSubmit } = useForm<FormValues>();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
value={field.value}
onChange={field.onChange}
error={fieldState.invalid}
/>
)}
/>
</form>
);
}
The Controller is the mechanism that the TextField re-renders when the email changes, and the Form does not . The render function is the mechanism that the field and the fieldState are the props, and the props are the reason the TextField is the isolated.
The useController is the reusable alternative. The component calls the hook internally, and the parent does not pass the control through the intermediate components.
// The useController reusable component
import { useController, type Control, type FieldPath, type FieldValues } from "react-hook-form";
function TextInput<T extends FieldValues>({ control, name, label }: TextInputProps<T>) {
const { field, fieldState } = useController({ control, name });
return <TextField label={label} {...field} error={fieldState.invalid} />;
}
The useController({ control, name }) is the mechanism that the TextInput is the connected. The parent passes the control and the name, and the TextInput is the isolated . The useController is the mechanism that the reusable component is the fast, and the fast is the reason the design-system component is the choice.
The React.memo is the separate mechanism. The memo prevents the field component from re-rendering when the parent re-renders for the unrelated reason.
// The React.memo for the field component
import { memo } from "react";
const EmailField = memo(function EmailField({ register, errors }: EmailFieldProps) {
return (
<div>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
);
});
The memo is the mechanism that the EmailField re-renders only when the props change. The parent’s unrelated re-render does not propagate to the EmailField . The useCallback on the handlers is the same mechanism, and the memo is the reason the unrelated re-render is the blocked.
The useFieldArray is the separate. The fields array is the metadata, and the key is the stable. The rows’ inputs are the uncontrolled, and the uncontrolled is the reason the typing is the fast.
// The useFieldArray with the stable keys
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<div>
{fields.map((field, index) => (
// The key is the stable, not the index.
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
</div>
))}
</div>
);
The key={field.id} is the mechanism that the rows are the stable. The register(items.${index}.name) is the mechanism that the input is the uncontrolled, and the uncontrolled is the reason the row is the fast.
Complete Example Session
// ============================================
// PART 1: THE NARROW FORMSTATE
// ============================================
function EmailField() {
const { register, formState: { errors } } = useForm<FormValues>();
return <input {...register("email")} />;
}
// The component subscribes only to the errors.
// ============================================
// PART 2: THE SUBMIT BUTTON
// ============================================
function SubmitButton() {
const { formState: { isSubmitting } } = useForm<FormValues>();
return <button disabled={isSubmitting}>Submit</button>;
}
// The component subscribes only to the isSubmitting.
// ============================================
// PART 3: THE WATCH IN THE PARENT
// ============================================
// โ The watch in the parent re-renders the whole form.
const email = watch("email");
// ============================================
// PART 4: THE USEWATCH IN THE CHILD
// ============================================
// โ
The useWatch in the child re-renders only the child.
function EmailDisplay({ control }: { control: Control<FormValues> }) {
const email = useWatch({ control, name: "email" });
return <p>{email}</p>;
}
// ============================================
// PART 5: THE COMPUTE
// ============================================
const count = useWatch({
control,
name: "items",
compute: (items = []) => items.length,
});
// The component re-renders only when the count changes.
// ============================================
// PART 6: THE CONTROLLER ISOLATION
// ============================================
<Controller
name="email"
control={control}
render={({ field }) => <TextField {...field} />}
/>
// The TextField re-renders, the Form does not.
// ============================================
// PART 7: THE USECONTROLLER
// ============================================
function TextInput<T extends FieldValues>({ control, name }: Props<T>) {
const { field } = useController({ control, name });
return <input {...field} />;
}
// The component is the reusable and the isolated.
// ============================================
// PART 8: THE REACT MEMO
// ============================================
const EmailField = memo(function EmailField({ register, errors }: Props) {
return <input {...register("email")} />;
});
// The memo blocks the unrelated parent re-render.
// ============================================
// PART 9: THE FIELD ARRAY KEYS
// ============================================
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
</div>
))}
// The field.id is the stable key.
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The formState is destructured narrowly.
// [ ] The useWatch is in the child, not the parent.
// [ ] The compute is used for the derived values.
// [ ] The Controller is isolated to the field.
// [ ] The useController is used for the reusable components.
// [ ] The field.id is the stable key.
The ten parts covered the narrow formState, the submit button, the watch in the parent, the useWatch in the child, the compute, the Controller isolation, the useController, the React.memo, the field array keys, and the checklist. The through-line is that the subscription is the scope, and the scope is the re-render count.
Quick Reference
The subscription mechanisms
| Mechanism | Re-renders |
|---|---|
The register | No re-render on typing |
The formState property | Only when the read property changes |
The watch | The component that calls it |
The useWatch | The component that calls the hook |
The Controller | The wrapped component |
The getValues | Nothing |
The optimization techniques
| Technique | Purpose |
|---|---|
The narrow formState | The subscription is the precise |
The useWatch | The child is the isolated |
The compute | The derived value is the re-render trigger |
The Controller | The field is the isolated |
The React.memo | The unrelated parent re-render is the blocked |
The field.id key | The rows are the stable |
The formState proxy
| Property | Re-renders |
|---|---|
errors | Only when the errors change |
isSubmitting | Only when the submission changes |
isDirty | Only when the dirty state changes |
Best Practices
โ Do This:
// Destructure the formState narrowly
const { formState: { errors } } = useForm(); // โ
// Use the useWatch in the child
const email = useWatch({ control, name: "email" }); // โ
// Use the compute for the derived values
compute: (items) => items.length // โ
// Use the field.id as the key
{fields.map((field) => <div key={field.id} />)} // โ
โ Don’t Do This:
// Destructure the whole formState
const { formState } = useForm(); // the every-property subscription // โ
// Use the watch in the parent
const email = watch("email"); // the whole form re-renders // โ
// Use the index as the key
{fields.map((field, index) => <div key={index} />)} // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The whole form re-renders on typing | The watch is in the parent | Use the useWatch in the child |
| The error display re-renders the form | The whole formState is destructured | Destructure the errors only |
| The rows mix up on remove | The index is the key | Use the field.id |
| The derived component re-renders too often | The compute is missing | Add the compute option |
Real-World Examples
1. The narrow errors
const { formState: { errors } } = useForm();
2. The narrow isSubmitting
const { formState: { isSubmitting } } = useForm();
3. The useWatch in the child
const email = useWatch({ control, name: "email" });
4. The compute
compute: (items = []) => items.length
5. The Controller
<Controller name="email" control={control} render={({ field }) => <TextField {...field} />} />
6. The useController
const { field } = useController({ control, name: "email" });
7. The React.memo
const EmailField = memo(function EmailField(props) { return <input {...props.register("email")} />; });
8. The stable key
{fields.map((field, index) => <div key={field.id}><input {...register(`items.${index}.name`)} /></div>)}
9. The getValues
const handleClick = () => getValues();
10. The useCallback
const handleSubmit = useCallback((data) => { save(data); }, [save]);
Visual
The Subscription Scope
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE SUBSCRIPTION SCOPE โ
โ โ
โ โ
The narrow formState: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ errors โ The error change re-renders โ
โ โ โ only the error component โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ The whole formState: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ formState โ Every change re-renders โ
โ โ โ the whole component โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The watch vs the useWatch
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE WATCH VS THE USEWATCH โ
โ โ
โ The watch in the parent: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The parent re- โ The whole form re-renders โ
โ โ renders โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The useWatch in the child: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The child re- โ The parent is untouched โ
โ โ renders โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The compute
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE compute OPTION โ
โ โ
โ Without the compute: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The re-render on โ Every keystroke โ
โ โ every items change โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ With the compute: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The re-render only โ Only when the count changes โ
โ โ when the count โ โ
โ โ changes โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
The narrow formState | The subscription is the precise |
The useWatch | The child is the isolated |
The compute | The derived value is the re-render trigger |
The Controller | The field is the isolated |
The React.memo | The unrelated parent re-render is the blocked |
The field.id key | The rows are the stable |
Key takeaways:
- The narrow
formStatedestructuring is the first optimization. TheformStateis the proxy that re-renders only the properties the component reads during the render. A component that destructures the whole object subscribes to every property, and the every-property is the reason the form re-renders on every interaction . - The
useWatchin the child re-renders only the child. Thewatchin the parent re-renders the whole form on every change, and theuseWatchscopes the re-render to the component that calls the hook . The extraction is the mechanism that the expensive subtree is the isolated. - The
computeoption reduces the re-render frequency. The function receives the watched value and returns the derived value, and the component re-renders only when the derived value changes. The total that depends on the array is the common case, and the count is the derived value that does not change on every keystroke . - The
Controllerisolates the controlled component. Therenderfunction re-renders the wrapped component when the field changes, and the parent form does not re-render. TheuseControlleris the hook form for the reusable component, and the isolation is the reason the controlled component is the fast . - The
field.idis the stable key. Theindexas the key causes the rows to mix up when the array is reordered or a row is removed. Thefield.idis the mechanism that the row identities are the preserved, and the preservation is the reason the inputs are the stable.
Remember: The React Hook Form performance is the subscription scope. The narrow formState is the precise, and the useWatch is the isolated. The compute is the derived value, and the Controller is the field. The React.memo blocks the unrelated parent re-render, and the field.id keeps the rows stable. The performance is the discipline, and the discipline is the scope.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!