| | |

React Hook Form 21 ๐Ÿ“‹ Performance Optimization โ€” Isolating subscriptions and avoiding re-renders

โฑ๏ธ Estimated read: 15 min | |

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

MechanismRe-renders
The registerNo re-render on typing
The formState propertyOnly when the read property changes
The watchThe component that calls it
The useWatchThe component that calls the hook
The ControllerThe wrapped component
The getValuesNothing

The optimization techniques

TechniquePurpose
The narrow formStateThe subscription is the precise
The useWatchThe child is the isolated
The computeThe derived value is the re-render trigger
The ControllerThe field is the isolated
The React.memoThe unrelated parent re-render is the blocked
The field.id keyThe rows are the stable

The formState proxy

PropertyRe-renders
errorsOnly when the errors change
isSubmittingOnly when the submission changes
isDirtyOnly 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

PitfallWhy It HappensFix
The whole form re-renders on typingThe watch is in the parentUse the useWatch in the child
The error display re-renders the formThe whole formState is destructuredDestructure the errors only
The rows mix up on removeThe index is the keyUse the field.id
The derived component re-renders too oftenThe compute is missingAdd 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

ItemValue
The narrow formStateThe subscription is the precise
The useWatchThe child is the isolated
The computeThe derived value is the re-render trigger
The ControllerThe field is the isolated
The React.memoThe unrelated parent re-render is the blocked
The field.id keyThe rows are the stable

Key takeaways:

  • The narrow formState destructuring is the first optimization. The formState is 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 useWatch in the child re-renders only the child. The watch in the parent re-renders the whole form on every change, and the useWatch scopes the re-render to the component that calls the hook . The extraction is the mechanism that the expensive subtree is the isolated.
  • The compute option 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 Controller isolates the controlled component. The render function re-renders the wrapped component when the field changes, and the parent form does not re-render. The useController is the hook form for the reusable component, and the isolation is the reason the controlled component is the fast .
  • The field.id is the stable key. The index as the key causes the rows to mix up when the array is reordered or a row is removed. The field.id is 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.

Was this helpful?


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!