Tự học ReactJS: React.useMemo vs. React.memo hạn chế re-render component như thế nào?

Ở 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.memoHOC 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 PureComponentshouldComponentUpdate ở 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 renderreconciliation 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:

  1. Props của nó thay đổi
  2. State bên trong của nó thay đổi
  3. Nó sử dụng context value bị thay đổi
  4. Nó connect tới store có state thay đổi (hay sử dụng useSelector với state select thay đổi)
  5. 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, shouldComponentUpdateReact.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 /><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.memouseCallback 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 highlightedIndexselectedItem, thay vì truyền vào indexobject, 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

Leave a Reply

Your email address will not be published. Required fields are marked *