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
Counter
component that has its properties being passed from the parent (App
). - We have prematurely optimized
onCounterAddClick
to only get recomputed ifnumCounter
changes. This was not necessary for the above example but it is illustrative to explain howuseCallback
will not prevent a re-render. - The
Counter
component 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
Counter
intoCounterContents
as 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
memo
is 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
onCounterMemoAddClick
function 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!