← Go Back

Should you optimize every re-render?

Published: Tue Dec 07 2021

One of the more frequent discussions I have with my colleagues is whether we should be investing the effort to optimize a React re-render (re-render).

React determines which components need to be re-rendered during the render phase. During this phase, React traverses the current Fiber tree and creates a list of effects that need to be applied to the Fiber nodes. The current tree reflects the state of the application that was used to render the UI. As React processes effects, it clones the current tree and performs these updates on the current tree, resulting in a new workInProgress tree. Once all updates are processed, React will flush the workInProgress tree to the DOM and this will become the current tree. If you’re interested in more detail, I cover the React Reconciliation algorithm in a separate post.

A render is usually required whenever your props or state change.

const MyComponent = React.memo(({ data }) => {
  return (
    <ul>
      {data.map((n) => (
        <li key={n}>{n}</li>
      ))}
    </ul>
  );
});

In the example above, if data changes, then we need to re-render the component with the latest values so these changes are reflected on the screen. Since we know that the component’s output is dependent on data, if data does not change, then there is no need to recalculate the output as that is also unchanged. This allows us to use React.memo or React.PureComponent.

What we do not want, is to re-render the component when data does not change. This is what I refer to as an unnecessary re-render.

Not all re-renders are bad

Not all re-renders are equal. Some re-renders are more CPU intensive than others. You may debug React re-rendering using the React Profiler Chrome extension.

Chrome DevTools React Profiler showing two panels. Left panel shows components which have been re-rendered. Right panel shows time spent on each re-render

The left column shows which components have been re-rendered, while the right column shows you how many times the component has re-rendered. Each re-rendering also includes the component’s self-time - the time it takes to execute the render() method for that component.

In most cases, the time spent rendering each component is a few milliseconds. This has led to the argument that not all unnecessary re-renders are bad; a statement I have to disagree with*.

While not all re-renders are equally important, I believe that all unnecessary re-renders should be eliminated to improve your applications reliability.

const ClickCounter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <>
      <button onClick={handleClick}>Update counter</button>
      <Counter count={count} />
      <MyComponent data={["A", "B", "C"]} />
    </>
  );
};

Demo: /src/ClickCounter.js

Whenever count is updated, MyComponent will be re-rendered, even if it is not dependent on count. This is caused as you are passing a new array reference on each render.

["A", "B", "C"] === ["A", "B", "C"]; // false

The correct solution would be to create a constant and place it outside of the ClickCounter component.

const data = ["A", "B", "C"];

const ClickCounter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <>
      <button onClick={handleClick}>Update counter</button>
      <Counter count={count} />
      <MyComponent data={data} />
    </>
  );
};

Demo: /src/ClickCounter.js

This no longer renders MyComponent whenever the user clicks on the button to update count. But how much faster is our application following this optimization? Most likely, the improvements are negligible. So does this mean you shouldn’t bother optimizing your re-renders?

const data = ["A", "B", "C"];

const Demo = () => {
  const location = useLocation();

  return (
    <>
      <span>{location.pathname}</span>
      <ul>
        <li>
          <MyComponent data={["A", "B", "C"]} />
        </li>
        <li>
          <MyComponent data={data} />
        </li>
      </ul>
    </>
  );
};

Demo: /src/Demo.js

In the example above, we have two instances of MyComponent; one which re-renders on each render and one which is correctly optimized. The Demo itself component will render whenever location changes, using the useLocation hook from React Router.

In most cases, these two components will behave identically. But in the first MyComponent, there is the premise for obscure bugs which would be hidden by causing the component to needlessly re-render.

const MyComponent = memo(({ data }) => {
  const location = window.location;

  return (
    <>
      <span>{location.pathname}</span>
      <ul>
        {data.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
    </>
  );
});

Demo: /src/MyComponent.js

If the rendered output of a component is dependent on more than its props, then needlessly re-rendering the component may hide this issue. In MyComponent, the rendered output includes the location.pathname, which would change whenever the URL changes. If the component does not re-render, then the updated location.pathname would not be visible on the screen. As a result, the MyComponent which needlessly re-renders would reflect this change on the next render, while the optimized component would not.

const MyComponent = memo(({ data }) => {
  const location = useLocation();

  return (
    <>
      <span>{location.pathname}</span>
      <ul>
        {data.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
    </>
  );
});

Of course in this example, we are simplifying things greatly and the issue is both easy to find and fix. However, from my experience, sometimes these bugs could go unnoticed for a very long time, making them very difficult to debug and eventually resolve.

Other unnecessary re-renders

There are other forms of unnecessary renders. Similar to the new array reference, passing an object or a function will cause the component to re-render.

return <MyComponent data={{ title: "Title" }} />;

This could be mitigated by either placing the object outside of the component’s render method or memoizing the prop using React.useMemo. The latter is usually required if the prop is dependent on other prop or state variables.

return (
  <MyComponent
    onClick={() => {
      doSomething(a, b);
    }}
  />
);

React includes React.useCallback which returns a memoized callback function.

const onClickHandler = React.useCallback(() => {
  doSomething(a, b);
}, [a, b]);

return <MyComponent onClick={onClickHandler} />;

Passing a React component as a prop will have a similar effect.

const Demo = () => {
  return <MyComponent header={<Header />} />;
};

And once again, the recommended solution is similar.

const Header = <Header />;

const Demo = () => {
  return <MyComponent header={Header} />;
};

Conclusion

While the performance improvements from optimizing every render may not always be significant, maintaining a pure codebase makes your work more predictable. If the output of every component is dependent on its inputs, then you minimize the risk of unwanted side effects. And you also get good performance “out-of-the-box”.

Let me know what you think. 🤙

*Ivan Akulov is an expert on web performance and I recommend reading his work. This is by no means an attempt at bashing a colleague’s work.

© 2024 Kevin Farrugia