Mustafa
Mustafa

Reputation: 981

Child Component Re-renders Due to useFormContext in React Hook Form

I'm experiencing a challenge with React Hook Form where my child component InputX re-renders every time there's an update in the parent component or other parts of the application, despite no changes in InputX itself. I’ve pinpointed that the re-renders are triggered by the use of useFormContext to access the register function within InputX.

Here's a brief outline of my setup:

I've noticed that when I remove useFormContext from InputX, the unnecessary re-renders stop. This leads me to believe that something about how useFormContext is interacting with register or the context setup might be causing these updates.

import { memo, useRef, useMemo, useEffect } from "react";
import {
  useFormContext,
  useWatch,
  useForm,
  FormProvider,
} from "react-hook-form";
import {
  MergeFormProvider,
  useMergeForm,
  useMergeFormUtils,
} from "./MergeFormProvider";

function InputX() {
  const { register, control } = useFormContext();

  const renderCount = useRef(0);
  const x = useWatch({ name: "x", control });

  renderCount.current += 1;
  console.log("Render count InputX", renderCount.current);
  const someCalculator = useMemo(() => x.repeat(3), [x]);

  return (
    <fieldset className="grid border p-4">
      <legend>Input X Some calculator {someCalculator}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("x")} placeholder="Input X" />
    </fieldset>
  );
}

function InputY() {
  const { register, control } = useFormContext();
  const renderCount = useRef(0);
  const y = useWatch({ name: "y", control });

  renderCount.current += 1;

  return (
    <fieldset className="grid border p-4">
      <legend>Input Y {y}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("y")} placeholder="Input Y" />
    </fieldset>
  );
}

function TodoByFormID() {
  const { formID } = useMergeForm();

  /**
   * Handle component by form id
   */

  return <div></div>;
}

const MemoInputX = memo(InputX);
const MemoInputY = memo(InputY);

function MainForm({ form }) {
  const { setFieldOptions } = useMergeFormUtils();

  const renderCount = useRef(0);
  renderCount.current += 1;

  const methods = useForm({
    defaultValues: form,
  });
  const [y, z] = useWatch({
    control: methods.control,
    name: ["y", "z"],
  });
  const fieldOptions = useMemo<ISelect[]>(() => {
    if (y.length) {
      return Array.from({ length: y.length }, (_, index) => ({
        label: index.toString(),
        value: index + ". Item",
      }));
    }

    return [];
  }, [y]);

  useEffect(() => {
    setFieldOptions(fieldOptions);
  }, [fieldOptions]);

  return (
    <FormProvider {...methods}>
      <fieldset>
        <legend>Main Form Y Value:</legend>
        {y}
      </fieldset>
      <MemoInputX />
      <MemoInputY />

      <fieldset className="grid border p-4">
        <legend>Input Z {z}</legend>
        <div>Render count: {renderCount.current}</div>
        <input {...methods.register("z")} placeholder="Input Z" />
      </fieldset>

      <TodoByFormID />
    </FormProvider>
  );
}

export default function App() {
  const formID = 1;
  const form = {
    count: [],
    x: "",
    y: "",
    z: "",
  };
  return (
    <MergeFormProvider initialFormID={formID}>
      <MainForm form={form} />
    </MergeFormProvider>
  );
}

For a clearer picture, I’ve set up a simplified version of the problem in this CodeSandbox: https://codesandbox.io/p/sandbox/39397x

Could anyone explain why useFormContext is causing these re-renders and suggest a way to prevent them without removing useFormContext? Any advice on optimizing this setup would be greatly appreciated!

I utilized useFormContext in InputX to simplify form handling and avoid prop drilling across multiple component layers in my larger project.

I expected that InputX would only re-render when its specific data or relevant form state changes (like its own input data).

Note:

Upvotes: 1

Views: 388

Answers (2)

Mustafa
Mustafa

Reputation: 981

It has been almost one week since I tried to understand why it re-renders. After several demonstrations and trials, I moved the FormProvider outside of MainForm.tsx and MergeFormProvider.tsx, which solved the issue. I realized that re-renders in MainForm.tsx were triggering the React Hook Form FormProvider, causing other child components to re-render as well. However, once I relocated the FormProvider, it stopped the unnecessary re-renders, including in the components where I use useFormContext.

This is fixed codebase: https://codesandbox.io/p/sandbox/react-hook-form-unnecessary-rerender-fixed-l679c7?workspaceId=afa5929a-cd92-42e4-8f73-bb3d8dbc7fe6

types.ts

export interface Form {
    x: string,
    y: string,
    z: string,
}

export interface ISelect {
    label: string;
    value: string;
}

RHFProvider.tsx

import { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Form } from "./types.ts";

interface Props {
    children: ReactNode;
    initialForm: Form
}

export default function RHFProvider({ children, initialForm }: Props) {
    const form = useForm<Form>({
        defaultValues: initialForm,
    })
    return (
        <FormProvider {...form}>
            {children}
        </FormProvider>
    )
}

MergeFormProvider.tsx

import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react";
import { ISelect } from "./types.ts";


export interface Props {
    children: ReactNode;
    initialFormID: number;
}

export interface IFormUtils {
    setFieldOptions: (fieldOptions: ISelect[]) => void;
}

export interface IFormData {
    fieldOptions: ISelect[];
    formID: number;
}

export const FormUtilsContext = createContext<IFormUtils | undefined>(
    undefined
);
export const FormDataContext = createContext<IFormData | undefined>(undefined);

export function MergeFormProvider({ children, initialFormID }: Props) {
    const [ formID, setFormID ] = useState<number>(initialFormID);
    const [ fieldOptions, setFieldOptions ] = useState<ISelect[]>([]);
    const formUtils = useMemo<IFormUtils>(
        () => ({
            setFieldOptions,
        }),
        [ setFieldOptions ]
    );
    const data = useMemo<IFormData>(
        () => ({
            fieldOptions,
            formID,
        }),
        [ fieldOptions, formID ]
    );

    useEffect(() => {
        setFormID(initialFormID);
    }, [ initialFormID ]);

    return (
        <FormUtilsContext.Provider value={formUtils}>
            <FormDataContext.Provider value={data}>
                {children}
            </FormDataContext.Provider>
        </FormUtilsContext.Provider>
    );
}

export function useMergeFormUtils() {
    const context = useContext(FormUtilsContext);
    if (!context) {
        throw new Error("useMergeFormUtils must be used within a FormUtilsContext");
    }
    return context;
}

export function useMergeForm() {
    const context = useContext(FormDataContext);
    if (!context) {
        throw new Error("useMergeForm must be used within a FormDataContext");
    }
    return context;
}

App.tsx

import { memo, useRef, useMemo, useEffect } from "react";
import {
    useFormContext,
    useWatch,
} from "react-hook-form";
import {
    MergeFormProvider,
    useMergeForm,
    useMergeFormUtils,
} from "./MergeFormProvider";
import RHFProvider from "./RHFProvider.tsx";
import { Form, ISelect } from "./types.ts";

function InputX() {
    const { control, register } = useFormContext<Form>()

    const renderCount = useRef(0);
    const x = useWatch({ name: "x", control });

    const someCalculator = useMemo(() => x.repeat(3), [ x ]);
    useEffect(() => {
        renderCount.current += 1;
        console.log("x", renderCount.current)
    })

    return (
        <fieldset className="grid border p-4">
            <legend>Input X Some calculator {someCalculator}</legend>
            <div>Render count: {renderCount.current}</div>
            <input {...register("x")} placeholder="Input X"/>
        </fieldset>
    );
}

function InputY() {
    const { control, register } = useFormContext<Form>()
    const renderCount = useRef(0);
    const y = useWatch({ name: "y", control });

    useEffect(() => {
        renderCount.current += 1;
    })

    return (
        <fieldset className="grid border p-4">
            <legend>Input Y {y}</legend>
            <div>Render count: {renderCount.current}</div>
            <input {...register("y")} placeholder="Input Y"/>
        </fieldset>
    );
}

function TodoByFormID() {
    const { formID } = useMergeForm();

    /**
     * Handle component by form id
     */
    console.log(formID);
// some other codes ....
    return <div>

    </div>;
}

const MemoInputX = memo(InputX);
const MemoInputY = memo(InputY);

function MainForm() {
    const { setFieldOptions } = useMergeFormUtils();
    const renderCount = useRef(0);
    const { control, register } = useFormContext<Form>()

    const [ y, z ] = useWatch({
        control,
        name: [ "y", "z" ],
    });
    const fieldOptions = useMemo<ISelect[]>(() => {
        if (y.length) {
            return Array.from({ length: y.length }, (_, index) => ({
                label: index.toString(),
                value: index + ". Item",
            }));
        }

        return [];
    }, [ y ]);

    useEffect(() => {
        renderCount.current += 1;
    })

    useEffect(() => {
        setFieldOptions(fieldOptions);
    }, [ fieldOptions, setFieldOptions ]);

    return (
        <section>
            <fieldset>
                <legend>Main Form Y Value:</legend>
                {y}
            </fieldset>
            <MemoInputX/>
            <MemoInputY/>

            <fieldset className="grid border p-4">
                <legend>Input Z {z}</legend>
                <div>Render count: {renderCount.current}</div>
                <input {...register("z")} placeholder="Input Z"/>
            </fieldset>

            <TodoByFormID/>
        </section>
    );
}

export default function App() {
    const formID = 1;
    const form: Form = {
        x: "",
        y: "",
        z: "",
    };
    return (
        <RHFProvider initialForm={form}>
            <MergeFormProvider initialFormID={formID}>
                <MainForm/>
            </MergeFormProvider>
        </RHFProvider>
    );
}

Upvotes: 0

Drew Reese
Drew Reese

Reputation: 203348

Could anyone explain why useFormContext is causing these re-renders and suggest a way to prevent them without removing useFormContext?

The useFormContext hook is not causing extra component rerenders. Note that your InputX and InputY components have nearly identical implementations*:

function InputX() {
  const { register, control } = useFormContext();

  const renderCount = useRef(0);
  const x = useWatch({ name: "x", control });

  renderCount.current += 1;
  console.log("Render count InputX", renderCount.current);
  const someCalculator = useMemo(() => x.repeat(3), [x]); // *

  return (
    <fieldset className="grid border p-4">
      <legend>Input X Some calculator {someCalculator}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("x")} placeholder="Input X" />
    </fieldset>
  );
}
function InputY() {
  const { register, control } = useFormContext();
  const renderCount = useRef(0);
  const y = useWatch({ name: "y", control });

  renderCount.current += 1;

  return (
    <fieldset className="grid border p-4">
      <legend>Input Y {y}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("y")} placeholder="Input Y" />
    </fieldset>
  );
}

* The difference being that InputX has an additional someCalculator value it is rendering.

and yet it's only when you edit inputs Y and Z that trigger X to render more often, but when you edit input X, only X re-renders.

This is caused by the parent MainForm component subscribing, i.e. useWatch, to changes to the y and z form states, and not x.

const [y, z] = useWatch({
  control: methods.control,
  name: ["y", "z"],
});
  • When the y and z form states are updated, this triggers MainForm to rerender, which re-renders itself and its entire sub-ReactTree, e.g. its children. This means MainForm, MemoInputX, MemoInputY, the "input Z" and all the rest of the returned JSX all rerender.
  • When the x form state is updated, only the locally subscribed InputX (MemoInputX) component is triggered to rerender.

If you updated MainForm to also subscribe to x form state changes then you will see nearly identical rendering results and counts across all three X, Y, and Z inputs.

const [x, y, z] = useWatch({
  control: methods.control,
  name: ["x", "y", "z"],
});

I expected that InputX would only re-render when its specific data or relevant form state changes (like its own input data).

React components render for one of two reasons:

  • Their state or props value updated
  • The parent component rerendered (e.g. itself and all its children)

InputX rerenders because MainForm rerenders.

Now I suspect at this point you might be wondering why you also see so many "extra" console.log("Render count InputX", renderCount.current); logs. This is because in all the components you are not tracking accurate renders to the DOM, e.g. the "commit phase", all the renderCount.current += 1; and console logs are unintentional side-effects directly in the function body of the components, and because you are rendering the app code within a React.StrictMode component, some functions and lifecycle methods are invoked twice (only in non-production builds) as a way to help detect issues in your code. (I've emphasized the relevant part below)

  • Your component function body (only top-level logic, so this doesn’t include code inside event handlers)
  • Functions that you pass to useState, set functions, useMemo, or useReducer
  • Some class component methods like constructor, render, shouldComponentUpdate (see the whole list)

You are over-counting the actual component renders to the DOM.

The fix for this is trivial: move these unintentional side-effects into a useEffect hook callback to be intentional side-effects. 😎

useEffect(() => {
  renderCount.current += 1;
  console.log("Render count Input", renderCount.current);
});

Input components:

function InputX() {
  const { register, control } = useFormContext();

  const renderCount = useRef(0);
  const x = useWatch({ name: "x", control });

  useEffect(() => {
    renderCount.current += 1;
    console.log("Render count InputX", renderCount.current);
  });

  const someCalculator = useMemo(() => x.repeat(3), [x]);

  return (
    <fieldset className="grid border p-4">
      <legend>Input X Some calculator {someCalculator}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("x")} placeholder="Input X" />
    </fieldset>
  );
}
function InputY() {
  const { register, control } = useFormContext();

  const renderCount = useRef(0);
  const y = useWatch({ name: "y", control });

  useEffect(() => {
    renderCount.current += 1;
    console.log("Render count InputY", renderCount.current);
  });

  return (
    <fieldset className="grid border p-4">
      <legend>Input Y {y}</legend>
      <div>Render count: {renderCount.current}</div>
      <input {...register("y")} placeholder="Input Y" />
    </fieldset>
  );
}

Any advice on optimizing this setup would be greatly appreciated!

As laid out above, there's really not any issue in your code as far as I can see. The only change to suggest was fixing the unintentional side-effects already explained above.

Upvotes: 2

Related Questions