Recently I had to deal with a very interesting bug that involved a component getting rendered multiple times causing performance problems. In this article we will look at how to use `memo` to stop functional components from rendering multiple times.
TLDR; When passing objects and functions as props to functional components in React.js, it might be necessary to use
memo and pass a custom function that checks for props equality. As added performance, use
useCallback on the parent to only recompute functions which will be used as props when their specific
dependencies change.
The problem
The essential problem at hand is that when passing a bunch of properties to a functional component in React, the default behaviour is to not check for any prop changes and simply render the component. The DOM is updated conditionally depending on changes but the simple act of rendering itself can cause performance issues for certain heavy components.
To test out the problem let us take the age old example of a simple counter, i.e. a component that takes a number and on a button click adds 1 to that number and displays it.
function App() {
const [numCounter, setNumCounter] = useState(0);
const onCounterAddClick = useCallback(() => {
setNumCounter(numCounter + 1);
}, [numCounter]);
return (
<div>
<Counter num={numCounter} onCounterAddClick={onCounterAddClick} />
</div>
);
}
function Counter({ num = 0, onCounterAddClick = () => {} }) {
// count the number of times this component is rendered.
console.count("Counter");
return <CounterContents num={num} onClick={onCounterAddClick} title="Counter" />;
}
function CounterContents({ num = 0, title = "", onClick = () => {} }) {
return (
<div style={{ margin: "5px" }}>
<h4>{title}</h4>
<div>
{num}
<button style={{ marginLeft: "5px" }} onClick={onClick}>
Add
</button>
</div>{" "}
</div>
);
}
There are a few points to note here:
- This is a pretty basic
Countercomponent that has its properties being passed from the parent (App). - We have prematurely optimized
onCounterAddClickto only get recomputed ifnumCounterchanges. This was not necessary for the above example but it is illustrative to explain howuseCallbackwill not prevent a re-render. - The
Countercomponent has aconsole.count("Counter")right before the return statement which is a pretty neat feature that lets the console count how many times this particular statement has been issued. - We have refactored the contents of
CounterintoCounterContentsas we will be reusing this for other counters that we make.
Running the above code will result in the number being updated on every click. Moreover, the component is rendered once per click which is very very exciting!
Now on to bigger and better things. Let us add another counter to this page which does pretty much the same thing but
comes with it's own number, let us call this CounterMemo as a sign of things to come.
function App() {
const [numCounter, setNumCounter] = useState(0);
const [numCounterMemo, setNumCounterMemo] = useState(0);
const onCounterAddClick = useCallback(() => {
setNumCounter(numCounter + 1);
}, [numCounter]);
const onCounterMemoAddClick = useCallback(() => {
setNumCounterMemo(numCounterMemo + 1);
}, [numCounterMemo]);
return (
<div>
<Counter num={numCounter} onCounterAddClick={onCounterAddClick} />
<CounterMemo num={numCounterMemo} onCounterMemoAddClick={onCounterMemoAddClick} />
</div>
);
}
function Counter({ num = 0, onCounterAddClick = () => {} }) {
// count the number of times this component is rendered.
console.count("Counter");
return <CounterContents num={num} onClick={onCounterAddClick} title="Counter" />;
}
function CounterMemo({ num = 0, onCounterMemoAddClick = () => {} }) {
// count the number of times this component is rendered.
console.count("CounterMemo");
return <CounterContents num={num} onClick={onCounterMemoAddClick} title="CounterMemo" />;
}
function CounterContents({ num = 0, title = "", onClick = () => {} }) {
return (
<div style={{ margin: "5px" }}>
<h4>{title}</h4>
<div>
{num}
<button style={{ marginLeft: "5px" }} onClick={onClick}>
Add
</button>
</div>{" "}
</div>
);
}
CounterMemo comes with its own state and logging but is otherwise exactly the same as
Counter. As usual, we've tried to be clever and pre-optimize onCounterMemoAddClick so that
it is computed only if numCounterMemo changes.
Hitting the click button on either of the counter components results in having both the components rendered. The
actual counter numbers are correct in html but the console spits out counts for both
Counter and CounterMemo components.
The solution
React.js v17 comes with a memo function that allows us to explicitly skip rendering of a component based
on whether the props have changed or not:
const CounterMemo = memo(
({ num = 0, onCounterMemoAddClick = () => {} }) => {
console.count("CounterMemo");
return <CounterContents num={num} onClick={onCounterMemoAddClick} title="CounterMemo" />;
},
(prevProps, nextProps) => {
if (prevProps.num !== nextProps.num) {
return false;
}
return true; // props are equal
}
);
- The second parameter to
memois a "arePropsEqual" function that takes the previous props and the next props as parameters. Returning true means props are equal no render is required and false means something has changed. - Here we know that we have memoized the
onCounterMemoAddClickfunction in the parent so we can safely ignore checking whether it has changed or not.
Hitting the click button on our newly memoized component results in both counters being rendered. However, clicking on the regular counter now will only render the regular counter! You can find the sample code for this experiment here. Happy memoizing!