Ở bài viết trước chúng ta đã tìm hiểu về React.useMemo
để tối ưu tính toán phức tạp trong component. Vậy React.memo
có giống useMemo
hay không và được sử dụng để làm gì?
React.memo
React.memo
là HOC trong React, nó nhận vào một component và trả về một component (memoized component) để hạn chế việc re-render component đó nếu các props của nó không thay đổi. Nó dùng để hạn chế re-render như trong PureComponent và shouldComponentUpdate ở class component. Khi bạn sử dụng functional component thì bạn có thể sử dụng React.memo
.
React.useMemo
Trong khi đó, React.useMemo
là một trong những React hook. Nó nhận vào một hàm và dependency list, hàm trong useMemo
sẽ không chạy lại nếu các dependency của nó không thay đổi.
Lifecycle
Lifecycle trong React được minh hoạ như sau:
→ render → reconciliation → commit
↖ ↙
state change
Sau khi chạy hàm render, React sẽ chạy giải thuật reconciliation và nó commit những thay đổi lên DOM thực. Sau đó nếu state thay đổi, thì nó tiếp tục gọi render và reconciliation rồi commit, cứ tiếp tục như vậy. Ba giai đoạn chính được định nghĩa như sau:
- Render: React tạo các elements với
React.createElement
- Reconciliation: so sánh những element trước và element hiện tại
- Commit: cập nhật DOM nếu như có thay đổi.
Component render khi nào?
Một React component có thể bị re-render bởi một trong những nguyên nhân sau:
- Props của nó thay đổi
- State bên trong của nó thay đổi
- Nó sử dụng context value bị thay đổi
- Nó connect tới store có state thay đổi (hay sử dụng useSelector với state select thay đổi)
- Cha của nó re-render
Bản thân React rất nhanh, nhưng trong một số trường hợp chúng ta cần hạn chế việc re-render của các component con vì có thể component đó quá nặng tính toán nhiều thứ và update nhiều DOM, … Bạn có thể sử dụng một số kỹ thuật như: PureComponent
, shouldComponentUpdate
và React.memo
để tối ưu việc re-render.
Nhưng Kent nhấn mạnh rằng đa số trường hơp chúng ta cần fix render chậm(tham khảo bài viết của Kent) trước khi sử dụng các kỹ thuật trên để tối ưu.
Giả sử chúng ta có 2 component con và 1 component cha như sau:
function CountButton({count, onClick}) {
return <button onClick={onClick}>{count}</button>
}
function NameInput({name, onNameChange}) {
return (
<label>
Name: <input value={name} onChange={e => onNameChange(e.target.value)} />
</label>
)
}
function Example() {
const [name, setName] = React.useState('')
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return (
<div>
<div>
<CountButton count={count} onClick={increment} />
</div>
<div>
<NameInput name={name} onNameChange={setName} />
</div>
{name ? <div>{`${name}'s favorite number is ${count}`}</div> : null}
</div>
)
}
Khi bạn click vào counter button thì component <Example />
sẽ bị re-render và tất nhiên các con của nó bao gồm <CountButton />
và <NameInput />
đều bị render lại. Nếu bạn sử dụng Profiler tab trong React DevTool bạn sẽ phát hiện lý do là do component cha <Example />
bị render.
Bởi vì chúng ta chỉ thay đổi props của component <CountButton />
khi increment nó, nhưng tại sao component <NameInput />
cũng bị re-render theo? Đó là vì React không biết có sự thay đổi các element trong component <NameInput />
hay không khi mà cha của nó có state thay đổi. Nên cách duy nhất là nó tự render lại. Nhưng biết đâu trong tương lai, React team sẽ cải thiện điều này. Nhưng có một điều là mặc dù <NameInput /> bị render trong trường hợp này nhưng DOM của nó không bị update hay thay đổi nên bạn yên tâm nó cũng không chậm lắm. Đây được gọi là render không cần thiết (unnecessary re-render)
Như đã nói ở trên, bạn có thể sử dụng PureComponent
trong class component hay React.memo
trong functional component (hai thằng này cơ bản như nhau) để hạn chế việc re-render không cần thiết của một component.
Bạn có thể sử dụng React.memo
như sau:
function CountButton({count, onClick}) {
return <button onClick={onClick}>{count}</button>
}
function NameInput({name, onNameChange}) {
return (
<label>
Name: <input value={name} onChange={e => onNameChange(e.target.value)} />
</label>
)
}
NameInput = React.memo(NameInput)
// code còn lại ...
Sau đó, bạn sẽ thấy component NameInput
sẽ không bị render lại nữa khi bạn click vào counter button. Nhưng bạn đừng quá lạm dụng React.memo
để tối ưu cho React app bằng cách component nào cũng wrap memo. Hãy sử dụng memo có mục đích rõ ràng và hiểu bản chất của nó làm gì. Sử dụng nhiều memo không đúng cũng làm chậm app của bạn (vì nó phải lưu giá trị cũ và so sánh bên trong). Kỹ thuật tối ưu performance đều có cái giá của nó. Nên hãy sử dụng memo
một cách khôn ngoan và có chủ đích. Mỗi dòng code đều có giá của nó.
Như ví dụ ở trên tại sao chúng ta không sử dụng React.memo
với component CountButton
. Bởi vì khi bạn thay đổi giá trị cho input trong NameInput
component, nếu có memo CountButton
thì nó cũng render lại bởi vì chúng ta phải xài thêm useCallback
cho onClick handler
thì mới fix được triệt để re-render. Trong một số trường hợp bạn cần kết hợp React.memo
và useCallback
hay gì đó để tối ưu hiệu quả. Cho nên hãy phân tích và xem xét kỹ khi sử dụng React.memo cho component của bạn.
Sử dụng React.memo để hạn chế render không cần thiết (unnecessary re-render)
Giả sử chúng ta có một ví dụ như sau:
import * as React from 'react'
import {useCombobox} from '../use-combobox'
import {getItems} from '../workerized-filter-cities'
import {useAsync, useForceRerender} from '../utils'
function Menu({
items,
getMenuProps,
getItemProps,
highlightedIndex,
selectedItem,
}) {
return (
<ul {...getMenuProps()}>
{items.map((item, index) => (
<ListItem
key={item.id}
getItemProps={getItemProps}
item={item}
index={index}
selectedItem={selectedItem}
highlightedIndex={highlightedIndex}
>
{item.name}
</ListItem>
))}
</ul>
)
}
// 🐨 Memoize the Menu sử dụng React.memo
function ListItem({
getItemProps,
item,
index,
selectedItem,
highlightedIndex,
...props
}) {
const isSelected = selectedItem?.id === item.id
const isHighlighted = highlightedIndex === index
return (
<li
{...getItemProps({
index,
item,
style: {
fontWeight: isSelected ? 'bold' : 'normal',
backgroundColor: isHighlighted ? 'lightgray' : 'inherit',
},
...props,
})}
/>
)
}
// 🐨 Memoize the ListItem sử dụng React.memo
function App() {
const forceRerender = useForceRerender()
const [inputValue, setInputValue] = React.useState('')
const {data: allItems, run} = useAsync({data: [], status: 'pending'})
React.useEffect(() => {
run(getItems(inputValue))
}, [inputValue, run])
const items = allItems.slice(0, 100)
const {
selectedItem,
highlightedIndex,
getComboboxProps,
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
selectItem,
} = useCombobox({
items,
inputValue,
onInputValueChange: ({inputValue: newValue}) => setInputValue(newValue),
onSelectedItemChange: ({selectedItem}) =>
alert(
selectedItem
? `You selected ${selectedItem.name}`
: 'Selection Cleared',
),
itemToString: item => (item ? item.name : ''),
})
return (
<div className="city-app">
<button onClick={forceRerender}>force rerender</button>
<div>
<label {...getLabelProps()}>Find a city</label>
<div {...getComboboxProps()}>
<input {...getInputProps({type: 'text'})} />
<button onClick={() => selectItem(null)} aria-label="toggle menu">
✕
</button>
</div>
<Menu
items={items}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
/>
</div>
</div>
)
}
export default App

Khi bạn click vào button force rerender thì tất cả các component như Memu
, ListItem
đều bị render lại mặc dù các props của nó không có thay đổi gì
. Hàm forceRender như sau:
const useForceRerender = () => React.useReducer(x => x + 1, 0)[1]
Cho nên trong trường hợp này chúng ta có thể tối ưu cho Menu
, ListItem
như sau:
Menu = React.memo(Menu)
ListItem = React.memo(ListItem)
Sử dụng hàm custom compare của React.memo
Ví dụ ở trên nếu bạn hover vào từng item của danh sách, bạn sẽ thấy item sẽ được hightlight. Nhưng điều kỳ lạ ở đây là khi bạn hover lên một item thì React phải render lại toàn bộ ListItem. Cho dù bạn đã sử dụng React.memo
như ở bài tập 1, nhưng toàn bộ ListItem
bị render lại. Bạn có thể sử dụng Profiler tab để kiểm tra xem tại đây.
Trong trường hợp này do khi chúng ta hover lên mỗi ListItem
thì highlightedIndex
bị thay đổi hay là props của component ListItem thay đổi nên dẫn tới việc re-render toàn bộ ListItem.
May mắn là React cho phép chúng ta truyền một compare function như một tham số của React.memo
để check xem props có thay đổi hay không và có cần render lại component ListItem
hay không? Trong trường hợp này chúng ta có thể check như sau:
ListItem = React.memo(ListItem, (prevProps, nextProps) => {
// true có nghĩa là KHÔNG rerender
// false có nghĩa là rerender
// những props này đơn giản chúng ta so sánh nếu khác giá trị trước thì rerender
if (prevProps.getItemProps !== nextProps.getItemProps) return false
if (prevProps.item !== nextProps.item) return false
if (prevProps.index !== nextProps.index) return false
if (prevProps.selectedItem !== nextProps.selectedItem) return false
// trường hợp với highlightedIndex, chúng ta cần render list item khi
// 1. trước đó, nó được highlight và hiện tại nó không được highlight
// 2. trước đó, nó không được highlight và hiện tại nó được highlight
if (prevProps.highlightedIndex !== nextProps.highlightedIndex) {
const wasPrevHighlighted = prevProps.highlightedIndex === prevProps.index
const isNowHighlighted = nextProps.highlightedIndex === nextProps.index
return wasPrevHighlighted === isNowHighlighted
}
return true
})
Chỉ truyền props primitive values cho ListItem
Liệu có cách nào cách không cần dùng custom compare function (vì nó hơi cồng kềnh) trong React.memo
mà vẫn đảm bảo được component ListItem
ko bị re-render? Có một cách khác là bạn có thể sửa lại props highlightedIndex
và selectedItem
, thay vì truyền vào index và object, chúng ta truyền vào kiểu boolean (primitive value).
Thay đổi component Menu và ListItem như sau:
function Menu({
items,
getMenuProps,
getItemProps,
highlightedIndex,
selectedItem,
}) {
return (
<ul {...getMenuProps()}>
{items.map((item, index) => (
<ListItem
key={item.id}
getItemProps={getItemProps}
item={item}
index={index}
isSelected={selectedItem?.id === item.id}
isHighlighted={highlightedIndex === index}
>
{item.name}
</ListItem>
))}
</ul>
)
}
Menu = React.memo(Menu)
function ListItem({
getItemProps,
item,
index,
isHighlighted,
isSelected,
...props
}) {
return (
<li
{...getItemProps({
index,
item,
style: {
backgroundColor: isHighlighted ? 'lightgray' : 'inherit',
fontWeight: isSelected ? 'bold' : 'normal',
},
...props,
})}
/>
)
}
ListItem = React.memo(ListItem)
Ở component Menu
bạn thấy rằng, chúng ta đã đổi code như sau:
isSelected={selectedItem?.id === item.id}
isHighlighted={highlightedIndex === index}
Hai props này giờ có giá trị true hoặc false. Điều này làm cho React.memo
so sánh dễ dàng hơn giữa giá trị cũ và giá trị mới. Ví dụ như khi bạn select một item trong list thì trước đây chúng ta truyền một object cho ListItem
, điều này làm cho tất cả các ListItem
đều render lại (mặc dù chúng ta chỉ chọn và thay đổi trạng thái của một item). Khi chọn một item thì object selectedItem
luôn bị gán thành object mới cho nên ListItem
phải bị re-render.
Bằng cách sử dụng kiểu boolean (thay selectedItem
object bằng isSelected
), mỗi khi chọn một item thì component Menu
tính toán và biết được item nào đươc chọn và chỉ thay đổi đúng giá trị isSelected
của item đó. Cho nên nó hạn chế render dư thừa cho những ListItem còn lại. Tương tự cho isHighlighted
.
Nguồn: Epic React by Kent C.Dodds