How to Avoid Excessive Re-rendering of React Components

Aleksandr Sokolov
5 min readApr 20, 2023

--

Image generated by AI

If you’re reading this article, you might have questions about this topic. Many of us know about useCallback and useMemo and consider the necessity of using these hooks in code. However, we often do this incorrectly. To a large extent, I believe this is a problem with the React documentation. There is an absence of a clear answer to the question: “When should we use these hooks?” But don’t worry. We’ll figure everything out and write excellent, performant, and readable code.

Part 1: A Brief Overview

If you look at the official documentation, React uses a virtual DOM, which is later transformed into HTML markup. Both the virtual and real DOMs are tree-like data structures.

React stores a virtual DOM tree (read as an object) in the computer’s memory and, with each change in data (useState, useSelector…), compares the new state of this tree with the current one, updating only what requires changes (which nodes of the virtual tree should be re-rendered). That’s why we are constantly warned to use a “key” in lists and not to use an index as the key.

Attention! Never do this:

items.map((item, index) => <SomeComponent key={index} name={item.name} />);

Since the virtual DOM tree is a tree-like data structure, any tree transformation operation has an O(N³) cubic complexity. However, the React team achieved linear complexity O(N) for the virtual DOM tree algorithm, thanks to their Reconciliation algorithm and heuristics.

In summary: using indexes as values for the “key” property is a bad idea due to the specific behaviour of the React library.

Part 2: React Reconciliation

The principle of this algorithm is quite simple; two trees are stored in memory — the Current Tree (what we see now in the browser) and the Work In Progress Tree, in which all modifications occur. For example, when data is updated in a specific node, React begins comparing the two trees, calculating the difference between them, and this difference is what gets passed on to rendering.

Example #1

The component tree used to be:

<div>
<span>
<SomeButtonComponent type="button">Click me</SomeButtonComponent>
</span>
</div>

The component tree now:

<span><!-- we've changed div to this span -->
<span>
<SomeButtonComponent type="button">Click me</SomeButtonComponent>
</span>
</span>

In this example, we replaced the <div> with a <span>, and React became a new tree node that will be changed entirely along with its child elements, meaning all the component lifecycle hooks will be called.

Example #2

const RootComponent = ({foo}: Props) => {
if (foo === 1) {
return (
<div>
<div>
<SomeComponentWithSelectors />
</div>
</div>
);
}

if (foo === 2) {
return (
<div>
<div>
<AnotherOneComponentWithSelectorsToo />
<SomeComponentWithSelectors />
</div>
</div>
);
}

return (
<div>
<SomeComponentWithSelectors/>
</div>
);
}

Since the number of elements varies depending on the value of the foo property, React will perform rendering starting from the quantitative difference in components or their types.

Short conclusion: pay attention to the nesting of elements and their types.

Part 3: Selectors and Redux

As the modern development world moved away from classes in favour of functions long ago, the useSelector hook is used for working with Redux. The discussion will focus on functional components and the useSelector hook.

Every time a dispatch of an action occurs modifying the state of the Redux Store, the corresponding useSelector working with the data changed by the action will be called. It is essential to understand that if the useSelector returns an object or array (anything passed by reference), the reference will be checked. If the references differ, a re-render will be performed.

One of the non-obvious factors affecting the component’s re-rendering.

Wrong way

export const selectTagsByProductId = (state, id) => state.product[id].tags || [];

Right way

const EMPTY_ARRAY = Immutable([]);

export const selectTagsByProductId = (state, id) => state.product[id].tags || EMPTY

Considering that several actions return at different points in time, this can provoke repeated calls to the useSelector and, consequently, re-rendering. Therefore, Redux, in its official documentation, recommends the following optimisation methods:

  1. Return scalar data types (string, number, boolean, undefined);
  2. Use libraries like reselect or similar;
  3. Use the shallowEqual function from the ‘react-redux’ package as the second argument.

Typical example of reselect usage

const selectUserTagById = (state, userId) => state.user[userId].tag;

const selectPictures = state => state.picture;

const selectRelatedPictureIds = createSelector(
[selectUserTagById, selectPictures],
(tag, pictures) => (
Object.values(pictures).filter(picture => picture.tags.includes(tag)).map(picture => picture.id)
)
)

How “reselect” understands when to take from the cache and when to recalculate:

  1. If the selector arguments have not changed (shallowEqual);
  2. If the results of inputSelectors have not changed (shallowEqual).

Part 4: Memoisation

In the final part, let’s discuss the standard means of optimising rendering with React.

React.memo

const MemoizedComponent = React.memo(props => <SomeComponent {...props} />, optionalShallowEqualFn);

As can be seen from the example, React.memo caches the component and re-renders it only if the properties have been changed. It’s important to understand that reference elements (Arrays, Objects, Functions) will affect rendering when a “new” value comes for the component each time. In this case, there will be no benefit from memoisation at all.

React.useCallback

const onClick = useCallback(callbackFunc, dependencyList);

Everything is simple: it returns a new function if something in the dependencyList has changed. Otherwise, it produces a reference to the function in memory, which will return true due to comparing prevProps and nextProps.

However, don’t be in a hurry to use useCallback. It is only needed for checking references. If you need to pass a function to a native DOM element (e.g., <button>, <div>, <input>, etc.), don’t use useCallback! You don’t need it. Native elements don’t compare references!

React.useMemo

const filteredArray = useMemo(() => someExpensiveOperation(), dependencyList);

It works similarly to useCallback. The main thing to remember is that if you don’t pass a dependency array as the second argument, a new value will be calculated on every render. In this case, React will not track changes in dependencies and will assume that the value can always change, even if the dependencies have not been actually changed. This can lead to excessive calculations, especially if the function passed to useMemo performs lengthy computations or accesses external data sources.

Conclusion

Using these hooks is generally beneficial when Functions, Objects, and Arrays are passed into optimised components that rely on reference equality to prevent unnecessary renders.

However, it’s important to remember that these hooks work effectively only together with memoised components, but not separately.

Bonus

I’ve prepared a small demo example for you. Try to play with it. Change the <AppNoMemoExample/> component to the <AppMemoExample/> component, and you will see how the button renders whenever the counter changes.

--

--

Aleksandr Sokolov
Aleksandr Sokolov

Responses (2)