Should you optimize every re-render?
Published: Tue Dec 07 2021One 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.
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"]} />
</>
);
};
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} />
</>
);
};
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>
</>
);
};
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>
</>
);
});
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. 🤙
Recommended Reading
- Inside Fiber: in-depth overview of the new reconciliation algorithm in React
- In-depth explanation of state and props update in React
*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.
Latest Updates
- Contributing to the Web Almanac 2024 Performance chapterMon Nov 25 2024
- Improving Largest Contentful Paint on slower devicesSat Mar 09 2024
- Devfest 2023, MaltaWed Dec 06 2023
- Learn Performance course on web.devWed Nov 01 2023
- Setting up a Private WebPageTest instanceMon Jun 26 2023