Fixing memoization-breaking re-renders in React
React’s component-based approach to building web applications allows us to break up our web apps into smaller components that we can easily reuse. When a component needs to update, React will trigger a re-render, and that’s how our application displays dynamic data, animations, etc. But there’s a situation where React re-renders components that don’t necessarily need re-rendering, and that hurts the performance of our applications.
One situation is when a parent component passes down a callback function to a child component through its props, and React re-renders the child component every time the parent component gets re-rendered, even though the child component is memoized. In this article, we’ll look into that problem, and learn how to fix it.
The problem
A parent component passes down a callback function to a child component through its props. The child component is memoized, but React still re-renders it every time the parent component gets re-rendered. Something breaks the memoization. Here’s how the parent and child components are being represented:
const ClosureRerender = () => {
const [number, setNumber] = useState(0);
const [count, setCount] = useState(0);
const onClick = (n) => {
setNumber(n);
};
return (
<>
<p>
Count: {count}
</p>
<button onClick={() => setCount((count) => count + 1)}>Increment</button>
<Number setNumber={onClick} />
<p>Number: {number}</p>
</>
);
};
function _Number(props) {
const array = new Uint32Array(5000);
for (const _ of Array(10000).fill(0)) {
crypto.getRandomValues(array);
}
const number = array[0];
return (
<button
onClick={() => {
props.setNumber(number);
}}
>
Set message to {number}
</button>
);
}
const Number = memo(_Number);
If you want to play with this code, visit its CodeSandbox link.
The _Number
component contains a heavy operation that happens on every re-render to demonstrate either a similar heavy operation or a larger subcomponent tree.
To identify that this is a problem, we’ll wrap all of the components we care about with Sentry’s withProfiler
method that comes with the React SDK. This will capture the ui.react.mount
and ui.react.update
events of that specific component. If we reload the app and click the “Increment” button a few times, we’ll see this in our Sentry Performance dashboard:
That longer transaction with 50% of the duration was spent on UI operations looks weird. That’s a good enough reason to dig deeper into it:
Yep, that’s bad! But how? We wrapped the Number
component with React’s memo()
. Why does it keep getting re-rendered?
Here’s what we know about React and re-rendering - React re-renders components whenever there’s a change in either their state or their props. Looking at the _Number
component, we’ll see that we don’t have any state variables defined, but we accept the setMessage
callback from the props. The issue happens when we click on the “Increment” button. Even though it has nothing to do with the Number
component at all, it forces the ClosureRerender
component to re-render, which in turn will recreate the onClick
method that gets passed down to the Number
component. The Number
component is memoized, but it receives a different value for the setMessage
prop every time its parent re-renders, and that makes it bypass the memoization and trigger a re-render.
Even though the onClick
method doesn’t change, its reference does. If you want to see for yourself, open the Console here on this page and type this in line by line:
const x = { name: ‘Lazar’ }
const y = { name: ‘Lazar’ }
x===y
The last x===y
command will output false
, even though both objects have the same name
property with the same value ’Lazar’
. JavaScript holds the reference as the value of the variable when we work with non-primitive types, and since we manually created both objects, x
and y
will have different references, and therefore x===y
will be false
.
That’s what React does too. The onClick
method gets recreated when ClosureRerender
re-renders, so we’re effectively passing down a brand new reference. The old setMessage
prop will not match the value of the new one, which makes React trigger a re-render of the Number
component. But how do we fix that? The solution might be obvious now.
The solution
We need to use the useCallback
hook! The useCallback
hook is to callbacks what useMemo
/memo()
is to components. It will prevent the callback from recreating as long as there are no changes in its dependency array. Here’s how the new onClick
method should look like:
const onClick = useCallback(
(number) => {
setNumber(number);
},
[setNumber],
);
We’re going to wrap the method with the useCallback
hook and put the props.setMessage
in the dependency array. Unless that changes, the onClick
will keep the same reference value between re-renders. Clicking on the “Increment” button will not trigger a re-render of the Number
component anymore, and we can validate that with Sentry:
Much better. No more unnecessary ui.react.update
events, and also no more long-running UI blocking tasks.
Conclusion
The useMemo
hook / memo()
method won’t always prevent our components from re-rendering unnecessarily. As you saw, there are situations where we can break the memoization and still end up with extra re-renders that hurt the performance and UX of our app. This can happen when we pass down callback methods that don’t use the useCallback
hook, but also when we define the callbacks inline like setMessage={(number) => props.setMessage(number)}
, and that’s something we might do out of habit, without paying much attention to what it will cause.
To validate that you fixed all of those situations in your whole app, and to start keeping an eye on the performance of your React app, integrate Sentry in your app. It’s free to get started, and it’s straightforward to install. If you want to learn more about what Sentry can do for your React app, visit the Sentry for React page.
We’re currently socializing for feedback on whether we should create Performance Issues specific to unnecessary re-renders in React, and we’d like to hear your thoughts. Having a specific Performance Issue for this will mean that you’ll be able to track all component re-rendering occurrences, automatically create JIRA tasks when they happen in your app, and even create Slack or Email alerts for it. Head to this GitHub Issue and feel free to join the discussion. The more people say that they want component re-render performance issues, the more priority it will put on that feature. Thank you!