Before reading, it is recommended to first read react-re-renders-guide to understand the theoretical knowledge and then deepen your understanding through practical business scenarios.
Make good use of React Devtool and Profiler#
There is a very useful feature in React devtool called "Highlight updates when components render". Using this feature, you can highlight re-rendered components. A single re-render is highlighted with a green border, and if there are multiple re-renders within a short period of time, it is highlighted with a yellow border, indicating that there may be a performance issue with that component.
With this tool, you can quickly identify which component on the page has a problem, but you cannot pinpoint the exact issue.
We know that a React component re-renders because of a certain hook, so the key is to find the hook that is causing frequent updates. This is where we need to use React Profiler for troubleshooting.
Switch to the Profiler Tab, click on Record in the upper left corner, and then trigger the operation that will cause a re-render. You will see the following page:
In the above image, NodeCards is a list component that iterates over NodeCard components. When hovering over a NodeCard, it causes the parent component, NodeCards, to re-render, resulting in all child components also being re-rendered. This is a significant performance overhead, and it is important to be aware of this in long list components.
Through the Profiler, we discovered that Hook 136 of NodeCards has changed, which caused this re-render. However, we don't know what exactly 136 refers to (criticizing React devtool). At this point, we need to switch back to the Components view, where you can see the corresponding component panel on the right side with the number of the Hook. By using the number, you can find the corresponding Hook. Of course, most Hooks are composed of multiple other hooks, so you can only make a rough judgment.
For example, in the image above, we can determine that Hook 136 is actually a Hook of Jotai.
How to avoid performance issues in list components#
Use Memo?#
In the example above, updating one component in the list causes the entire list to update, which is very inefficient. By observing a few frames in the Profiler, we can see that most NodeCards themselves are not updated, only the upper-level NodeCards are updated. In this case, we can use memo to wrap NodeCard. This way, updating the parent component will not cause all list elements to update. In list scenarios, it is generally recommended to wrap the list elements (ListItem) with memo. This space-for-time optimization is very beneficial for long lists, unless you are certain that the list state will not be updated.
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
return <div />
})
Pass id
instead of data source?#
Normally, we can directly pass the data source when iterating over the list data, as shown above. However, due to the immutable nature of React, if the data source changes, your component will be updated, even if the specific value of the data source is not used in the ListItem. For example:
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// we update data[0].desc
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
const { name } = props.data
return <div>{name}</div>
})
Even if NodeCard does not consume desc
, it will be re-rendered once the data changes. This is not what we want to see. However, if we pass in the id and use a selector, we can avoid this non-environmentally friendly behavior.
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// maybe some state change and hook update here.
// we update data[0].desc
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard id={item.id} />)}
</>
}
const NodeCard = memo((props) => {
const { id } = props
const name = useAtomValue(
selectAtom(
dataMapAtom,
useCallback((dataMap) => dataMap.id.name, [])
)
)
return <div>{name}</div>
})
Where should the state of list elements be maintained?#
Perhaps we will encounter scenarios where different UI expressions need to be displayed based on a certain state in the list. For example, when a card is hovered over, I need to perform some motion or other actions that cannot be achieved simply with CSS. For example:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} activeId={activeId} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
The above code is very wrong. In a list, you should not directly pass these dynamically mutable values to list elements. For example, the above code will cause all NodeCards to be re-rendered when hovering over a NodeCard.
The correct approach is to pass a boolean value and complete the judgment in NodeCards:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} isActive={activeId === item.id} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
By modifying it to isActive
, this problem will not occur.
Furthermore, if I just want to get activeId
but NodeCard is an ExpensiveComponent, what should I do? One method is to use Ref, but Ref is not part of the reactive data flow. Another method is to use an external state from a state library like Jotai + splitting lightweight components.
const activeIdAtom = atom(0)
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCardExpensiveComponent data={item} />)}
</>
}
const NodeCardExpensiveComponent = memo((props) => {
const setActiveId = useSetAtom(activeIdAtom)
// do thing.
return <>
<div onMouseEnter={() => {
props.setActiveId(props.data.id)
}}
/>
{new Array(10000).fill(0).map(() => <div />)}
<ObservedActiveIdHandler />
</>
})
const ObservedActiveIdHandler = () => {
const activeId = useAtomValue(activeIdAtom)
useEffect(() => {
// do thing.
}, [activeId])
}
By splitting the reactive handling of activeId
to a lightweight component, it will not affect the expensive component.
This method is also applicable to optimization in other areas.
Discovering performance issues from the Performance flame graph#
Chrome devtools has a very useful flame graph tool. When the page is idle but the CPU usage is high and React devtool does not highlight any re-rendered components, this tool comes in handy.
By capturing a segment of the flame graph, you can identify CPU usage that is too high within a short period of time. In the image above, the time slices in the flame graph are very dense, and such a high number of JS calls should not occur when the page is idle. In this case, you can use the Call Tree for further investigation.
Timer Fired indicates a problem caused by a timer. By following this clue, we discovered that a colleague had mistakenly written a setInterval with a delay of 0.
These are some of today's sharing. I plan to write about how to use Jotai & Zustand, granularize components, and sink parent component state into child components in the future.
This article is synchronized and updated to xLog by Mix Space.
The original link is https://innei.ren/posts/programming/experience-in-performance-optimization-in-react-applications-1