Reputation: 981
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:
InputX
component utilizes useFormContext
specifically for the register
function.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:
Upvotes: 1
Views: 388
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
Reputation: 203348
Could anyone explain why
useFormContext
is causing these re-renders and suggest a way to prevent them without removinguseFormContext
?
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"],
});
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.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:
state
or props
value updatedInputX
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)
useState
, set
functions, useMemo
, or useReducer
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