Share on Twitter
Share on Facebook
Share on HackerNews
Share on LinkedIn

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:

UI Heavy Transction

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:

span-waterfall

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:

validate

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!

Your code is broken. Let's Fix it.
Get Started

More from the Sentry blog

ChangelogCodecovDashboardsDiscoverDogfooding ChroniclesEcosystemError MonitoringEventsGuest PostsMobileOpen SourcePerformance MonitoringRelease HealthResourceSDK UpdatesSentry
© 2024 • Sentry is a registered Trademark
of Functional Software, Inc.