React Hooks and Tips to Avoid Useless Component Render Applied on Lists
Louise Loisel11 min read
A few weeks ago, I encountered children list rerender issues on the project I was working on. In this article you will learn :
- how I debugged a react performance issue
- why virtualization is not always suitable for list rendering issues
- what is memoization
- how to memoize react components and functions with react hooks and React.memo() to prevent a component from re-rendering.
Overview on the performance issue
I’m working on a platform to purchase orders from suppliers. Each order is composed of clothing items. Here is a typical example of the interface.
Let’s take a very simple example: I am ordering from my supplier Theodo, producing some top quality shirts. I want to order their famous shirts in three colors (white, red and blue). On the left there is a sidebar where you can select a card, here there are three cards, one for each color. On the right there is a form with the information relative to the selected card.
When another card is clicked, the form values on the right is now displaying the information related to the new selected item.
For the new year, a big order has to be done containing more than 600 items. In this situation, the list takes a huge amount of time to load, or doesn’t even load at all. And if I’m lucky enough to have the 600 items displayed inside the sidebar, when another item is clicked on, I’m also waiting… This performance issue is terrible in terms of user experience and needs to be fixed!
First, I need to make sure whether the performance issue actually comes from React. To do so, the user interaction can be recorded with the Google devTools performance tab. When JavaScript is executed it is accounted in scripting. Here, scripting is taking most of the time. I can investigate more on what is happening.
I then used the React profiler to see if this long scripting task comes from long react render times or useless component re-renders. The profiler is included in the React Dev Tools Chrome extension. It records renders, why your components rendered and for how long.
Long story short, you mainly need to understand that a colored bar means that your component rendered, grey bar means it did not.
And guess what? When an item in the list is clicked on, all the items contained in the list re-rendered 🥵! When the list is only 3 components long, it is ok to recompute but with 600 components, each click becomes quite expensive.
For example here is the result in the profiler when the second card in the sidebar is clicked on. Basically, everything re-rendered.
Profiler view when an other card is clicked on, everything renders
So the React rendering step needs to be lightened. What options do we got to perform this?
-
Find a way to reduce the number of items that need to re-render
It can be done thanks to virtualization. Virtualization is a technique to compute in the DOM only the elements located inside the user’s viewport. When the user scrolls, elements will be added/deleted in the DOM. A few librairies already implement this for us https://github.com/bvaughn/react-window.
-
Find a way to avoid useless re-renders
Why virtualization is not the right solution here?
Virtualization is a good option when your rendering phase is taking a long time. But here, it’s the re-renders that take time. If re-renders issues are solved, displaying 600 items only once is not an issue.
Moreover, the average user is making orders of about 50 items without a powerful computer. Virtualization is not very useful for short lists.
Finally, the height of each item card is variable (when a field is filled, the item card will grow), and the number of item is variable (the list can be filtered, items can be created or deletes). Windowing librairies are not well suited for variable items size or number, some custom functions have to be added to make it work. For the user this can lead to a laggy scroll. And re-renders issues won’t be solved.
So let’s solve those useless re-renders!
Why do components re-render when another card is clicked on?
The structure is the following: OrderItemsSelection
is the parent component, it contains the Form
section and the Sidebar
. The Sidebar
itself has children: the SidebarCards
(as many as items).
OrderItemsSelection
has a state, selectedItem
, and a state setter, setSelectedItem
for the selected item id (thanks to a useState
hook).
OrderItemsSelection
passes selectedItem
as a prop for the Form
section and the Sidebar
.
OrderItemsSelection
also passes setSelectedItem
setter to the Sidebar
, Sidebar
passes the setSelectedItem
setter to its SidebarCard
children to be used on card click.
So when the red shirt card is clicked on, a new state is setted with setSelectedItem
, the selected card is now the red shirt card.
As the selected card is a state of the component OrderItemsSelection
, it re-renders: a component state change triggers a re-render.
Therefore all its children also re-render: the Form
section, and the Sidebar
. As the Sidebar
re-renders, so are its children: the white/red/blue shirts cards.
In “components” tab the value attributed to Hook 1 is displayed (which caused a render)
Component updating schema
But in fact what do we truly need to render in the sidebar? If we take a closer look, the blue shirt card does not need to be rendered as none of its props actually change.
How to prevent re-renders
What is memoization?
Let’s take a look at Wikipedia definition:
In computing, memoization or memoisation is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.
So here, the Sidebar
is a parent component of each card component. So we want to cache the blue card component computations because it has unchanged prop values, ie memoize this component. We can find more information about component memoization in the React documentation:
React.memo
is a higher order component. If your component renders the same result given the same props, you can wrap it in a call toReact.memo
for a performance boost in some cases by memoizing the result. This means that React will skip rendering the component, and reuse the last rendered result.
Memoize a React component with React.memo()
Ok so if everything goes as planned I just have to add the memoization to SidebarCard
component, and React will magically understand that if props values didn’t change, the component does not need to re-render.
Before memoized component
export default SidebarCard;
After memoized component
export default memo(SidebarCard);
But it would be too easy won’t it be?
When we launch the profiler and focus on the blue shirt card:
We can see that components are memoized thanks to (Memo) just after component name, but we can also see that React considered that props did change for the last card: sidebarItems
and onSetSelected
.
What’s interesting is that it recognized that the isSelected boolean did not change.
It’s a boolean so the comparison is only made based on the boolean value.
With functions, array and object, it’s different.
Even if the objects have the same properties, they are considered equal only if it refers to the exact same object.
const Laura1 = { name: "Laura" };
const Laura2 = { name: "Laura" };
Laura1 === Laura2;
// => false
Functions, as well as arrays and objects are stored by reference into memory and not by value. We call these non-primitive data types as opposed to primitive types (string, boolean, number, undefined or null) which are stored by value. Here is a great article to understand all about it.
Memoize a function with useCallback()
This means that a new function onSetSelected
was created earlier, which has the same value as the former but not the same reference.
They are two different entities even they look alike.
onSetSelected
is a function that is called when a card is clicked on to set the new selected item id.
onSetSelected
is passed from the sidebar to the card here:
const Sidebar = ({
onSetSelected, // => received here
sidebarItems,
selectedItemId,
allowMultiSelect = false,
}: SidebarProps): JSX.Element => {
return (
<div>
{sidebarItems.map((sidebarItem, index) => (
<SidebarCard
sidebarItem={sidebarItem}
positionIndex={index}
onSetSelected={onSetSelected} // => passed here
isSelected={selectedItemsId === sidebarItem.id}
/>
))}
</div>
)};
onSetSelected
is defined in the Sidebar
parent:
const OrderItemsSelection: React.FunctionComponent = () => {
const { initialValues, submit, selectedItemId, setSelectedItemId } =
useItemsSelection();
// => onSetSelected definition
const onSetSelected = (index: number) => {
setSelectedItemId(index);
};
return (
<Formik initialValues={initialValues} onSubmit={submit}>
{({ values, validateForm }: FormikProps<ItemsSelectionValues>) => {
return (
<>
<Sidebar
sidebarItems={values}
onSetSelected={onSetSelected} // => passed here
selectedItemId={selectedItemId}
/>
<ItemsSelectionForm
initialValues={values.items[selectedItemId]}
selectedItemId={selectedItemId}
/>
</>
);
}}
</Formik>
);
};
So when the OrderItemsSelection
component re-renders, a new onSetSelected
is generated. The blue shirt card receives a new function (weirdly same looking as the previous one). So it has to re-render.
We want to avoid recomputing the function, as nothing has changed except the re-render in OrderItemsSelection
. In short, we need to memoize the function.
Fortunately, React has already implemented this for us. A big welcome to the beautiful useCallback
hook which will memoize our function!
Before without useCallback
const onSetSelected = (index: number) => {
setSelectedItemId(index);
};
With useCallback
const onSetSelected =
useCallback((index: number) =>
{
setSelectedItemId(index);
}
, []);
Now it’s time to try if our onSetSelected
is still a guilty prop.
Profiler with memoized onSetSelected function
Component updating schema
Nice shot, onSetSelected
is not guilty anymore, but we still have one targeted prop: the sidebarItem
.
Use React.memo() comparison function to tell React when a component should re-render
Same protocol, as done before, where is sidebarItem
defined?
const Sidebar = ({
onSetSelected,
sidebarItems,
selectedItemId,
allowMultiSelect = false,
}: SidebarProps): JSX.Element => {
return (
<div>
{sidebarItems.map((sidebarItem, index) => ( // => defined here
<SidebarCard
sidebarItem={sidebarItem} // => passed here
positionIndex={index}
onSetSelected={onSetSelected}
isSelected={selectedItemId === sidebarItem.id}
/>
))}
</div>
)
};
When Sidebar
renders it recomputes the map, and each sidebarItem
is recomputed.
Since it’s a brand new object and as explained earlier, with a new reference, so it is not equal to the previous object even if it contains the exact same values.
So how can we tell our card component to take into account only deep equality of previous and current sidebarItem
?
Once again, React team has already a solution right in the React.memo doc:
By default it will only shallowly compare complex objects in the props object. If you want control over the comparison, you can also provide a custom comparison function as the second argument.
Nice, the next step is to define a function that can compare the SidebarCard
props in a customized way.
Stringify is not suitable for deep comparison
We could have thought to compare the stringifying version of the two objects, but it’s not the best idea as when we have equal properties not in the same order, it would have returned false.
const Laura1 = { name: "Laura" };
const Laura2 = { name: "Laura" };
JSON.stringify(Laura1) === JSON.stringify(Laura2);
// => true
const Laura1 = { name: "Laura", age: 25 };
const Laura2 = { age: 25, name: "Laura" };
JSON.stringify(Laura1) === JSON.stringify(Laura2);
// => false
We can use a deep equality. A deep equality is an equality based on what the object owns. Lodash provides isEqual
to evaluate deep comparison.
const Laura1 = { name: "Laura", age: 25 };
const Laura2 = { age: 25, name: "Laura" };
JSON.stringify(Laura1) === JSON.stringify(Laura2);
// => false
isEqual(Laura1, Laura2);
// => true
Therefore, the isEqual
lodash function is a wiser choice to compare the props.
Before custom comparison
export default memo(SidebarCard);
With custom comparison
const compareProps = (
propsBefore: SidebarCardProps,
propsAfter: SidebarCardProps
) => {
return isEqual(propsAfter, propsBefore);
};
export default memo(SidebarCard, compareProps);
Drum roll…
🥳 Yeah, got it! So for the 600 items list, when the second card is clicked on, here is the after memoization:
What if we only use memoize custom comparison without useCallback?
The isEqual
on the onSetSelected
function would have worked, the card wouldn’t have re-rendered.
But it was a nice learning in case you have a function which is triggering a re-render.
Is there a difference between useMemo and useCallback?
useMemo
is usually used for variable memoization. But you can use a useMemo
to memoize a function.
useCallback(fn, deps)
is equivalent touseMemo(() => fn, deps)
.
Why not always use memoization?
Memoizing is not free, it costs memory and the comparison cost. It adds complexity to your code so it will need more efforts to read/to refactor.
Conclusion
Thanks to React.memo() HOC and memoization hooks, useless re-renders were avoided! I hope you’ve learned a bunch of things!
Go further:
React: Fantastic Hooks and How to Use Them
React-Virtualized: Why, When and How you should use it