skip to Main Content

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:

  • The InputX component utilizes useFormContext specifically for the register function.
  • The form is managed by a FormProvider in the parent component.

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:

  • The CodeSandbox link provided is a minimized version of my project. In the full project, I have components several layers deep (grand grand children).

2

Answers


  1. Chosen as BEST ANSWER

    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>
        );
    }
    

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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search