React Performance Optimization Tips
Harsh
Posted on December 27, 2020
In this post, we will look into how we can improve the performance of React apps that need to render a lot of components on the screen.
We generally consider using pagination
or virtualization
for most of our apps to provide a better user experience and that works for most of the use cases, but what if we have a use case where we need to render a lot of components on the screen while not giving up on the user experience and performance.
For the demonstration, I have considered a simple app that renders 30k squares on the screen, and we update the count when the squares are clicked. I am using react 17.0.0
and functional components with hooks.
Here is the preview of the app. It has an App
component and a Square
component. There is a noticeable lag on clicking the squares.
Stackblitz Preview
Stackblitz Code
// App.jsx
import React, { useState } from "react";
import Square from "./components/square/square";
const data = Array(30000)
.fill()
.map((val, index) => {
return { id: index, key: `square-${index}` };
});
const App = () => {
const [count, setCount] = useState(0);
const [items, setItems] = useState(data);
return (
<div>
<p>Count: {count}</p>
{items.map(({ key, id, clicked }) => (
<Square
key={key}
id={id}
clicked={clicked}
onClick={id => {
const newItems = [...items];
newItems[id].clicked = true;
setCount(val => val + 1);
setItems(newItems);
}}
/>
))}
</div>
);
};
export default App;
// Square.jsx
import React from "react";
import "./square.css";
const Square = ({ onClick, id, clicked }) => {
return (
<div
className={`square ${clicked && "clicked"}`}
onClick={() => onClick(id)}
/>
);
};
export default Square;
Let's add console statements to both the components to check if they are rendering unnecessarily and then click on one of the squares. We see the Square
component function is getting called 30k times.
Also, we can see that 600ms
are spent in re-rendering the UI on React Dev tools Profiler Tab. Start the profiling on page load -> click any square -> stop profiling.
We need to avoid re-rendering of Square
component as none of the props
for it is changing. We will use React.memo
for this.
What is React.memo
?
React.memo
is a higher order component that helps to skip the re-rendering by memoizing the result of the initial render. React.memo
re-renders component only when the prop
changes.
NOTE:
React.memo
does a shallow comparison. For more control, we can pass a comparison function as below.React.memo(Component, (prevProps, nextProps) => { // return true if the props are same, this will skip re-render // return false if the props have changed, will re-render });
Here is the Square
component with React.memo
// Square component with React.memo
import React from "react";
import "./square.css";
const Square = ({ onClick, id, clicked }) => {
return (
<div
className={`square ${clicked && "clicked"}`}
onClick={() => onClick(id)}
/>
);
};
export default React.memo(Square);
Now let's try to profile again with an additional setting as shown below.
We don't see any difference yet. But when we hover on the Square
component it shows onClick
prop has changed which has triggered this re-render. This happens as we are passing a new function during each render for the onClick
prop. To avoid this we use useCallback
.
What is useCallback
?
useCallback
is a hook that returns a memoized callback.
// App component with useCallback
import React, { useState, useCallback } from "react";
import Square from "./components/square/square";
const data = Array(30000)
.fill()
.map((val, index) => {
return { id: index, key: `square-${index}` };
});
const App = () => {
const [count, setCount] = useState(0);
const [items, setItems] = useState(data);
const onClick = useCallback(
id => {
const newItems = [...items];
newItems[id].clicked = true;
setCount(val => val + 1);
setItems(newItems);
},
[items]
);
return (
<div>
<p>Count: {count}</p>
{items.map(({ key, id, clicked }) => (
<Square key={key} id={id} clicked={clicked} onClick={onClick} />
))}
</div>
);
};
export default App;
Let's profile again. We are now avoiding re-rendering of Squares
and this reduces the time to 118ms
.
We see much better performance now. We are avoiding the re-rendering of Square
components using memoization but React
still needs to compare the props for all the 30k elements. Here is the component tree for our app.
If you still find performance issues we can go one step further. We have 30k Square
elements below the App
component. To reduce the time React takes to compare props we need to reduce the components at this level. What can be done here? Can we introduce another layer of components? Yes, we will be splitting the list of 30k items into smaller chunks and render those by using an intermediate component.
In a real-world app, we can find a logical place to split the list into smaller chunks. But here let's split them into chunks of 500 Squares each.
// App component
import React, { useState, useCallback } from "react";
import Row from "./components/row/row";
let num = 0;
const data = Array(30000)
.fill()
.map((val, index) => {
if (index % 500 === 0) {
num = 0;
}
return { id: num++, key: `square-${index}` };
});
const chunkArray = (array, chunkSize) => {
const results = [];
let index = 1;
while (array.length) {
results.push({
items: array.splice(0, chunkSize),
key: String(index)
});
index++;
}
return results;
};
const chunks = chunkArray(data, 500);
const App = () => {
const [count, setCount] = useState(0);
const [allItems, setAllItems] = useState(chunks);
const onClick = useCallback(
(id, index) => {
const chunk = [...allItems[index].items];
chunk[id].clicked = true;
setCount(val => val + 1);
allItems[index].items = chunk;
setAllItems(allItems);
},
[allItems]
);
return (
<div>
<p>Count: {count}</p>
{allItems.map(({ items, key }, index) => (
<Row items={items} onClick={onClick} key={key} index={index} />
))}
</div>
);
};
export default App;
// Row component
import React, { useCallback } from "react";
import Square from "../square/square";
const Row = ({ items, onClick, index }) => {
const onItemClick = useCallback(
id => {
onClick(id, index);
},
[onClick, index]
);
return (
<>
{items.map(({ id, key, clicked }) => (
<Square key={key} onClick={onItemClick} id={id} clicked={clicked} />
))}
</>
);
};
export default React.memo(Row);
Let's profile again. We do not see any lag now. We have a lot fewer Row
components so the prop comparison is pretty quick also React can skip Square
prop comparison if the Row
props have not changed.
Here is the final app
Stackblitz Preview
Stackblitz Code
React.memo
and useCallback
can be used to get better performance. Does it mean we should wrap all components with React.memo
and all functions with useCallback
? No. React.memo
and useCallback
use memoization which adds up to the memory, also the functions themselves take time to run and have overheads like the prop comparison. The splitting that we have done adds up to the memory as well.
When to use React.memo
and useCallback
?
They are not required unless you see some lag in a specific component or the complete app. If there is a lag try profiling for the actions on that screen and check if there can be any component re-renders that can be avoided. useCallback
is also useful in cases where we are using the functions as dependencies for hooks to avoid unnecessary code blocks to run.
Conclusion
While React.memo
, useCallback
, useMemo
can be used to optimize the performance of the React apps they are not required in most cases. Use them Cautiously.
Posted on December 27, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.