Optimizing Lists in React - Solving Performance Problems and Anti-patterns
Federico Terzi
Posted on February 16, 2022
I'm Federico, a Software Engineer specialized in Frontend Development and System Programming. You can find out more about my work on Twitter, YouTube and GitHub.
This post originally appeared on my personal blog.
React is the most popular front-end framework, and that’s for a reason. Besides being funded by one of the largest companies on the planet, it’s also built around a few key concepts (one-way data flow, immutable data, functional components, hooks) that make it easier than ever to create robust applications. That said, it’s not without pitfalls.
It’s easy to write inefficient code in React, with useless re-renders being the common enemy. Usually, you start from a simple application and gradually build features on top of it. At first, the application is small enough to make the inefficiencies unnoticeable, but as the complexity grows, so does the component hierarchy, and thus, the number of re-renders. Then, once the application speed becomes unbearable (according to your standards), you start profiling and optimizing the problematic areas.
In this article, we are going to discuss the optimization process for lists, which are notorious sources of performance problems in React. Most of these techniques apply to both React and React Native applications.
Starting from a problematic example
We’ll start from a problematic example and gradually discuss the process of identifying and solving the different issues.
The proposed example is a simple list of selectable items, with a few performance problems. Clicking on an item toggles the selection status, but the operation is visibly laggy. Our goal is to make the selection feel snappy. You can find the complete code as follows (a Codesandbox is also available).
import { useState } from "react";
// Create mock data with elements containing increasing items
const data = new Array(100)
.fill()
.map((_, i) => i + 1)
.map((n) => ({
id: n,
name: `Item ${n}`
}));
export default function App() {
// An array containing the selected items
const [selected, setSelected] = useState([]);
// Select or unselect the given item
const toggleItem = (item) => {
if (!selected.includes(item)) {
setSelected([...selected, item]);
} else {
setSelected(selected.filter((current) => current !== item));
}
};
return (
<div className="App">
<h1>List Example</h1>
<List data={data} selectedItems={selected} toggleItem={toggleItem} />
</div>
);
}
const List = ({ data, selectedItems, toggleItem }) => {
return (
<ul>
{data.map((item) => (
<ListItem
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
</ul>
);
};
const ListItem = ({ name, selected, onClick }) => {
// Run an expensive operation to simulate a load
// In real-world JS applications, this could be either a custom
// JS elaboration or a complex render.
expensiveOperation(selected);
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
onClick={onClick}
>
{name}
</li>
);
};
// This is an example of an expensive JS operation that we might
// execute in the render function to simulate a load.
// In real-world applications, this operation could be either a custom
// JS elaboration or just a complex render
const expensiveOperation = (selected) => {
// Here we use selected just because we want to simulate
// an operation that depends on the props
let total = selected ? 1 : 0;
for (let i = 0; i < 200000; i++) {
total += Math.random();
}
return total;
};
If you want to practice, feel free to pause reading and try to spot the problems yourself first
Let’s dive into the analysis.
Missing key prop
The first thing we can notice from the console is that we are not passing the key
prop when rendering the list items.
which is caused by this code:
{data.map((item) => (
<ListItem
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
As you might already know, the key
prop is critical for dynamic lists to work correctly in React, as it helps the framework identify which items have changed, are added, or are removed.
A common beginners’ anti-pattern is to solve the problem by passing the item’s index:
{data.map((item, index) => (
<ListItem
key={index}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
Despite working for simple use-cases, this approach leads to multiple unexpected behaviors when the list is dynamic, with items being added or removed. For example, if you delete an item in the middle of a list at index N, then all list items located at positions N+1 will now have a different key. That causes React to “confuse” which mapped component belongs to which items. If you want to know more about the potential pitfalls of using the index as key, this article is a great resource.
Therefore, you should specify a key prop with something that uniquely identifies the item being rendered. If the data you’re receiving is coming from a backend, you might be able to use the database’s unique id as key. Otherwise, you could generate a client-side random id with nanoid when creating the items.
Luckily, each of our own items has it’s own id property, so we should handle it as follows:
{data.map((item) => (
<ListItem
key={item.id}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
Adding the key solves the previous warning, but we still have a significant lag when selecting an item. It’s time to go serious and open the profiler.
Profiling the list
Now that we solved the key
warning, we are ready to tackle the performance problem. At this stage, using a profiler can help to track down the slow areas and therefore guide our optimization, so that’s what we are going to do.
When working with React, there are two main profilers you can use: the browser’s built-in profiler, such as the one available inside Chrome’s Dev Tools, and the profiler provided by the React DevTools extension. Both of them are useful in different scenarios. From my experience, the React DevTools’ profiler is a good starting point, as it gives you a component-aware performance representation, which is helpful to track down the specific components that are causing problems, whereas the browser’s profiler works at a lower level and it’s mostly helpful in those cases where the performance problems are not directly related to a component, for example, due to a slow method or Redux reducer.
For this reason, we are going to start with the React DevTools’ profiler, so make sure to have the extension installed. Then, you can access the Profiler tool from Chrome’s dev tools > Profiler. Before starting, we are going to set up two settings that will help us in the optimization process:
- In Chrome’s Performance tab, set CPU throttling to x6. That will simulate a slower CPU, making slowdowns much more evident.
- In React DevTools Profiler tab, click on the Gear icon > Profiler > “Record why each component rendered while profiling”. This will help us track down the causes for useless re-renders.
Once the configuration is done, we are ready to profile our sample todo app. Go ahead and click on the Record button, then select some items in the list and, finally, hit stop recording. This is the result we obtain after selecting 3 items:
On the top right side, you see highlighted in red the commits, which, in short, are the renders that caused the DOM to update. As you can see, the current commit took 2671 milliseconds to render. By hovering on the various elements, we can tell that most of the time is spent rendering the list items, with an average of 26 milliseconds per item.
Spending 26 milliseconds rendering a single item is not inherently bad. As long as the entire operation takes less than 100ms, the action would still be perceived as snappy by the user. Our biggest problem is that selecting a single item causes all the items to be re-rendered, and that’s what we are going to tackle in the next section.
A question we should ask ourselves at this point is: what’s the expected number of items to re-render after an action? In this particular case, the answer is one, as the result of a click is a new item being selected, with none of the others being affected. Another scenario might be a single-selection list, where at most one item could be selected at any given time. In that case, clicking on an item should cause the re-render of two items, as we need to render both the selected one and the one being unselected.
Preventing re-renders with React.memo
In the previous section, we discussed how selecting a single item causes the entire list to be re-rendered.
Ideally, we would like to re-render only the items whose "looks" are affected by the new selection.
We can do that using the React.memo higher-order component.
In a nutshell, React.memo
compares the new props with the old ones and, if they are equal, it reuses the previous render.
Otherwise, if the props are different, it re-renders the component.
It's important to note that React executes a shallow comparison of the props, which must be taken into account when passing objects and methods as props.
You can also override the comparison function, though I would advise against it, as it makes the code less maintainable (more on this later).
Now that we know the basics of React.memo
, let's create another component by wrapping the ListItem
with it:
import { memo } from "react";
const MemoizedListItem = memo(ListItem);
We can now use MemoizedListItem
instead of ListItem
in the list:
{data.map((item) => (
<MemoizedListItem
key={item.id}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
Nice! We now have memoized the ListItem
. If you go ahead and try the application, you'll notice something is wrong...
The application is still slow!
If we open up the profiler as we previously did and record a selection, we should be presented with something like the following:
As you can see, we are still re-rendering all the items! Why is it happening?
If you hover on one of the list items, you'll see the "Why did this render?" section. In our case, it says Props changed: (onClick)
,
which means our items are re-rendering due to the onClick
callback we are passing to each item.
As we previously discussed, React.memo
does a shallow comparison of the props by default.
Which basically means calling the strick equality operator ===
over each prop. In our case, the check would
be roughly equivalent to:
function arePropsEqual(prevProps, nextProps) {
return prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected &&
prevProps.onClick === nextProps.onClick
}
While name
and selected
are compared by value (because they are primitive types, string and boolean respectively), onClick
is compared
by reference (being a function).
When we created the list items, we passed the onClick
callback as an anonymous closure:
onClick={() => toggleItem(item)}
Every time the list re-renders, each item receives a new callback function.
From an equality perspective, the callback has changed, and therefore the MemoizedListItem
is re-rendered.
If the equality aspect is still unclear to you, go ahead and open the JS console inside your browser.
If you typetrue === true
, you'll notice that the result istrue
.
But if you type(() => {}) === (() => {})
, you'll getfalse
as result.
That's because two functions are equal only if they share the same identity, and
every time we create a new closure we generate a new identity.
Therefore, we need a way to keep the identity of the onClick
callback stable to prevent useless re-renders,
and that's what we are going to discuss in the next sections.
A common anti-pattern
Before discussing the proposed solution, let's analyze a common (anti-)pattern being used in these cases.
Given that the React.memo
method accepts a custom comparator, you might be tempted to provide one that
artifically excludes onClick
from the check. Something like the following:
const MemoizedListItem = memo(
ListItem,
(prevProps, nextProps) =>
prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected
// The onClick prop is not compared
);
In this case, even with a changing onClick
callback, the list items won't be re-rendered unless name
or selected
are updated.
If you go ahead and try this approach, you'll notice the list feels snappy now, but something is wrong:
As you can see, selecting multiple items doesn't work as expected now, with items being randomly selected and unselected.
This is happening because the toggleItem
function is not pure, as it depends on the previous value of the selected
items.
If you exclude the onClick
callback check from the React.memo
comparator, then your components might receive an outdated (stale)
version of the callback, causing all those glitches.
In this particular case, the way the toggleItem
is implemented is not optimal and we can easily convert it to a pure function
(in fact, we are going to do that in the next section). But my point here is: by excluding the onClick
callback from the memo
comparator, you're exposing the application to subtle staleness bugs.
Some might argue that as long as the onClick
callback is kept pure, then this approach is perfectly acceptable.
Personally, I consider this an anti-pattern for two reasons:
- In complex codebases is relatively easy to transform a pure function into a non-pure one by mistake.
- By writing a custom comparator, you're creating an additional maintenance burden. What if the
ListItem
needs to accept anothercolor
parameter in the future? Then, you'll need to refactor to the comparator, as shown below. If you forget to add it (which is relatively easy in complex codebases with multiple contributors), then you are again exposing your component to staleness bugs.
const MemoizedListItem = memo(
ListItem,
(prevProps, nextProps) =>
prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected &&
prevProps.color === nextProps.color
);
If a custom comparator is not advisable, what should we do to solve this problem then?
Making callback identities stable
Our goal is to use the "base" version of React.memo
without a custom comparator.
Choosing this path will both improve the maintainability of the component and its robustness against future changes.
For the memoization to work correctly though, we'll need to refactor the callback to keep its identity stable, otherwise
the equality check performed by React.memo
will prevent the memoization.
The traditional way to keep function identities stable in React is to use the useCallback
hook.
The hook accepts a function and a dependency array, and as long as the dependencies won't change, neither will the identity of the callback.
Let's refactor our example to use useCallback
:
Our first attempt is to move the anonymous closure () => toggleItem(item)
inside a separate method inside useCallback
:
const List = ({ data, selectedItems, toggleItem }) => {
const handleClick = useCallback(() => {
toggleItem(??????) // How do we get the item?
}, [toggleItem])
return (
<ul>
{data.map((item) => (
<MemoizedListItem
key={item.id}
name={item.name}
selected={selectedItems.includes(item)}
onClick={handleClick}
/>
))}
</ul>
);
};
We are now facing a problem: previously, the anonymous closure captured the current item
in the .map
iteration and then passed it to the toggleItem
function as an argument. But now, we are not declaring the handleClick
handler inside the iteration, so how can we access the "selected item" in the callback?
Let's discuss a possible solution:
Refactoring the ListItem component
Currently, the ListItem
's onClick
callback doesn't provide any information about the item being selected.
If it did, we would be able to easily solve this problem, so let's refactor the ListItem
and List
components to provide this information.
Firstly, we change the ListItem
component to accept the full item
object, and given that the name
prop is now redundant we remove it.
Then, we introduce an handler for the onClick
event to also provide the item
as argument. This is our end result:
const ListItem = ({ item, selected, onClick }) => {
// Run an expensive operation to simulate a load
// In real-world JS applications, this could be either a custom
// JS elaboration or a complex render.
expensiveOperation(selected);
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
onClick={() => onClick(item)}
>
{item.name}
</li>
);
};
As you can see, the onClick
now provides the current item as a parameter.
But wait! You used an anonymous closure again in the
li
'sonClick
handler, shouldn't we avoid them to prevent re-renderings?
While we could create another memoized callback withuseCallback
inside theListItem
component to handle the click event, that would offer no performance improvements in this case.
The problem with the anonymous closure we discussed earlier in theList
item was that it broke theReact.memo
memoization for theMemoizedListItem
. Given that we are not memoizing theli
element, then there is no performance benefit from having a stable identity for this callback.
We can then refactor the List
component to pass the item
prop instead of name
and to make use of the newly available item
information in the handleClick
callback:
const List = ({ data, selectedItems, toggleItem }) => {
const handleClick = useCallback(
(item) => { // We now receive the selected item
toggleItem(item);
},
[toggleItem]
);
return (
<ul>
{data.map((item) => (
<MemoizedListItem
key={item.id}
item={item} // We pass the full item instead of the name
selected={selectedItems.includes(item)}
onClick={handleClick}
/>
))}
</ul>
);
};
Nice! Let's go ahead and try the refactored version:
It works... but it's still slow! If we open up the profiler, we can see the whole list is still being rendered:
As you can see from the profiler, the onClick
identity is still changing! That means our handleClick
identity is being changed at every re-render.
Another common anti-pattern
Before diving into the proper solution, let's discuss a common anti-pattern used in these cases.
Given that the useCallback
accepts a dependency array, you could be tempted to specify an empty one to keep the identity fixed:
const handleClick = useCallback((item) => {
toggleItem(item);
}, []);
Despite keeping the identity stable, this approach suffers from the same staleness bugs we discussed in previous sections.
If we run it, you'll notice the items get unselected as it happened when we specified the custom comparator:
In general, you should always specify the correct dependencies in useCallback
, useEffect
and useMemo
, otherwise, you're
exposing the application to potentially hard-to-debug staleness bugs.
Solving the toggleItem identity problem
As we previously discussed, the problem with our handleClick
callback is that its toggleItem
dependency identity changes at each render, causing it to re-render as well:
const handleClick = useCallback((item) => {
toggleItem(item);
}, [toggleItem]);
Our first attempt is to wrap toggleItem
with useCallback
as we did with handleClick
:
const toggleItem = useCallback(
(item) => {
if (!selected.includes(item)) {
setSelected([...selected, item]);
} else {
setSelected(selected.filter((current) => current !== item));
}
},
[selected]
);
This does not solve the problem though, as this callback depends on the external state variable selected
, which changes every time setSelected
is called. If we want its identity to remain stable, we need a way to make toggleItem
pure. Luckily, we can use useState
's functional updates to accomplish our goal:
const toggleItem = useCallback((item) => {
setSelected((prevSelected) => {
if (!prevSelected.includes(item)) {
return [...prevSelected, item];
} else {
return prevSelected.filter((current) => current !== item);
}
});
}, []);
As you can see, we wrapped our previous logic inside the setSelected
call, which in turn provides the previous state value we need to compute the new selected items.
If we go ahead and run the refactored example, it works and it's also snappy! We can also run the usual profiler to get a sense of what's happening:
Hovering on the item being rendered:
As you can see, after selecting an item we only render the current one being selected now, while the others are being memoized.
A note on functional state updates
In the example we just discussed, converting our toggleItem
method to the functional mode of useState
was relatively trivial.
In real-world scenarios, things might not be as straightforward.
For example, your function might depend on multiple state pieces:
const [selected, setSelected] = useState([]);
const [isEnabled, setEnabled] = useState(false);
const toggleItem = useCallback((item) => {
// Only toggle the items if enabled
if (isEnabled) {
setSelected((prevSelected) => {
if (!prevSelected.includes(item)) {
return [...prevSelected, item];
} else {
return prevSelected.filter((current) => current !== item);
}
});
}
}, [isEnabled]);
Every time the isEnabled
value changes, your toggleItem
identity will change as well.
In these scenarios, you should either merge both sub-states into the same useState
call, or even better, convert it to a useReducer
one.
Given that useReducer
's dispatch
function has a stable identity, you can scale this approach to complex states.
Moreover, the same applies to Redux's dispatch
function, so you can move the item toggle logic at the Redux level and convert our toggleItem
function to something as:
const dispatch = useDispatch();
// Given that the dispatch identity is stable, the `toggleItem` will be stable as well
const toggleItem = useCallback((item) => {
dispatch(toggleItemAction(item))
}, [dispatch]);
Virtualizing the list?
Before closing the article, I wanted to briefly cover list virtualization, a common technique used to improve performance for long lists.
In a nutshell, list virtualization is based on the idea of rendering just a sub-set of the items in a given list (generally the currently visible ones) and deferring the others.
For example, if you have a list with a thousand items but only 10 are visible at any given time, then we might only render these 10 first, and the others can be rendered on-demand when needed (i.e. after scrolling).
List virtualization offers two main advantages compared to rendering the entire list:
- Faster initial start time, as we only need to render a subset of the list
- Lower memory usage, as only a subset of the items is being rendered at any given time
That said, list virtualization is not a silver bullet you should always use, as it increases complexity and can be glitchy.
Personally, I'd avoid virtualized lists if you are only dealing with hundreds of items, as the memoization techniques we discussed in this article are often effective enough (older mobile devices might require a lower threshold). As always, the right approach depends on the specific use case, so I'd highly recommend profiling your list before diving into more complex optimization techniques.
We are going to cover virtualization in a future article. In the meanwhile, you can read more about virtualized lists in React, with libraries like react-window, and in React Native, with the built-in FlatList component.
Conclusion
In this article, we covered list optimization in depth. We started from a problematic example and gradually solved most of the performance problems.
We also discussed the main anti-patterns you should be aware of, along with potential ways to solve them.
In conclusion, lists are often the cause of performance problems in React, as all items are being re-rendered every time something changes by default.
React.memo
is an effective tool to mitigate the issue, but you might need to refactor your application to make your props' identities stable.
The final code is available in this CodeSandbox if you're interested.
PS: there's one small useMemo
optimization left to add in our example, can you spot it yourself? :)
Posted on February 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.