Tự học ReactJS: React Performace – Tối ưu context value

Tự học ReactJS: React Performace – Tối ưu context value

Khi sử dụng context trong React, chúng ta nên lưu ý khi value của context provider thay đổi thì tất cả những component sử dụng context đó sẽ bị render lại. Nó re-render ngay khi chúng ta đã memo component đó.

Giả sử chúng ta có Counter Context như sau:

const CountContext = React.createContext()

function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = [count, setCount]
  return <CountContext.Provider value={value} {...props} />
}

Nếu CountProvider re-render thì const value = [count, setCount] được tạo mới, cho dù count, setCount không thay đổi. Do đó nó sẽ làm cho tất cả các consumer re-render.

Một trong những cách fix nhanh nhất là chúng ta có thể sử dụng React.useMemo như sau:

const CountContext = React.createContext()

function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = React.useMemo(() => [count, setCount], [count])
  return <CountContext.Provider value={value} {...props} />
}

Điều này đảm bảo rằng nếu lỡ như CountProvider có re-render vì một lý do không mong muốn nào đó thì value sẽ không thay đổi nếu count không đổi.

Sử dụng React.memo để tối ưu context value

Giả sử chúng ta có một app như sau. Nó cũng tương tự như một react app của chúng ta thường thấy, bạn hãy tưởng tượng có một grid data và từng cell như là một component trong app. Nó rất giống với cách tổ chức component tree trong real app. Bạn có thể tăng số lượng render column và row của grid trong demo. Giả sử app của bạn cũng có hàng ngàn component như vây(50×50).

Trong react app rất dễ khi chúng ta vô tình re-render App component, điều này làm cho tất cả các component con của App re-render. Và tất nhiên có thể làm chậm app rất nhiều. Ví dụ khi bạn click vào button force render trong demo trên, toàn App bị re-render.

function App() {
  const forceRerender = useForceRerender()
  return (
    <div className="grid-app">
      <button onClick={forceRerender}>force rerender</button>
      <AppProvider>
        <div>
          <DogNameInput />
          <Grid />
        </div>
      </AppProvider>
    </div>
  )
}

Điều tệ hơn force render làm thay đổi context value của <AppProvider /> dẫn tới re-render tất cả consumer của nó.

Nhưng trong trường hợp này chúng ta có thể sử dụng bảo bối React.memo để tối ưu như sau:

function AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, {
    dogName: '',
    grid: initialGrid,
  })
  const value = React.useMemo(() => [state, dispatch], [state])
  return (
    <AppStateContext.Provider value={value}>
      {children}
    </AppStateContext.Provider>
  )
}

const value = React.useMemo(() => [state, dispatch], [state]) giá trị value thay đổi khi và chỉ khi state thay đổi.

Bạn có thể tham khảo toàn bộ code của demo trên:

import * as React from 'react'
import {
  useForceRerender,
  useDebouncedState,
  AppGrid,
  updateGridState,
  updateGridCellState,
} from '../utils'

const AppStateContext = React.createContext()

const initialGrid = Array.from({length: 100}, () =>
  Array.from({length: 100}, () => Math.random() * 100),
)

function appReducer(state, action) {
  switch (action.type) {
    case 'TYPED_IN_DOG_INPUT': {
      return {...state, dogName: action.dogName}
    }
    case 'UPDATE_GRID_CELL': {
      return {...state, grid: updateGridCellState(state.grid, action)}
    }
    case 'UPDATE_GRID': {
      return {...state, grid: updateGridState(state.grid)}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, {
    dogName: '',
    grid: initialGrid,
  })
  const value = React.useMemo(() => [state, dispatch], [state])
  return (
    <AppStateContext.Provider value={value}>
      {children}
    </AppStateContext.Provider>
  )
}

function useAppState() {
  const context = React.useContext(AppStateContext)
  if (!context) {
    throw new Error('useAppState must be used within the AppProvider')
  }
  return context
}

function Grid() {
  const [, dispatch] = useAppState()
  const [rows, setRows] = useDebouncedState(50)
  const [columns, setColumns] = useDebouncedState(50)
  const updateGridData = () => dispatch({type: 'UPDATE_GRID'})
  return (
    <AppGrid
      onUpdateGrid={updateGridData}
      rows={rows}
      handleRowsChange={setRows}
      columns={columns}
      handleColumnsChange={setColumns}
      Cell={Cell}
    />
  )
}
Grid = React.memo(Grid)

function Cell({row, column}) {
  const [state, dispatch] = useAppState()
  const cell = state.grid[row][column]
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = React.memo(Cell)

function DogNameInput() {
  const [state, dispatch] = useAppState()
  const {dogName} = state

  function handleChange(event) {
    const newDogName = event.target.value
    dispatch({type: 'TYPED_IN_DOG_INPUT', dogName: newDogName})
  }

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label htmlFor="dogName">Dog Name</label>
      <input
        value={dogName}
        onChange={handleChange}
        id="dogName"
        placeholder="Toto"
      />
      {dogName ? (
        <div>
          <strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
        </div>
      ) : null}
    </form>
  )
}

function App() {
  const forceRerender = useForceRerender()
  return (
    <div className="grid-app">
      <button onClick={forceRerender}>force rerender</button>
      <AppProvider>
        <div>
          <DogNameInput />
          <Grid />
        </div>
      </AppProvider>
    </div>
  )
}

export default App

Tách nhỏ context value

Hiện tại context của chúng ta lưu hai giá trị: dogName & grid. Bạn hãy thử thay đổi Dog Name input và kiểm tra Profiler tab thì thấy tất cả cell trong grid component cũng bị re-render.

Tại sao chúng ta chỉ thay đổi dogName nhưng grid lại re-render? Xem lại context value:

const [state, dispatch] = React.useReducer(appReducer, {
    dogName: '',
    grid: initialGrid,
})
const value = React.useMemo(() => [state, dispatch], [state])

Khi dogName thay đổi thì state thay đổi và value của context cũng bị thay đổi nên consumer (Grid) cũng re-render.

Hơn thế nữa khi bạn click vào một cell trên Grid thì nó cũng bị re-render. Tại sao trong component Grid chúng ta chỉ sử dụng const dispatch = useAppDispatch() và dispatch function không đổi trong trường hợp này. Vậy tại sao Grid lại re-render?

Đó là do state thay đổi dẫn tới value thay đổi, dẫn tới re-render tất cả consumer.

Có một cách là chúng ta có thể tách context hiện tại thành hai phần. Một phần cho state, một phần cho dispatch function như sau:

unction AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, {
    dogName: '',
    grid: initialGrid,
  })
  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  )
}

Với sự thay đổi này thì khi thay đổi state (ví dụ như bạn click vào cell, thay đổi Dog Name input) thì hàm dispatch sẽ không thay đổi và component Grid sẽ không re-render.

Component Grid màu xám có nghĩa là nó không bị re-render nữa

Toàn bộ code thay đổi như sau:

import * as React from 'react'
import {
  useForceRerender,
  useDebouncedState,
  AppGrid,
  updateGridState,
  updateGridCellState,
} from '../utils'

const AppStateContext = React.createContext()
const AppDispatchContext = React.createContext()

const initialGrid = Array.from({length: 100}, () =>
  Array.from({length: 100}, () => Math.random() * 100),
)

function appReducer(state, action) {
  switch (action.type) {
    case 'TYPED_IN_DOG_INPUT': {
      return {...state, dogName: action.dogName}
    }
    case 'UPDATE_GRID_CELL': {
      return {...state, grid: updateGridCellState(state.grid, action)}
    }
    case 'UPDATE_GRID': {
      return {...state, grid: updateGridState(state.grid)}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, {
    dogName: '',
    grid: initialGrid,
  })
  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  )
}

function useAppState() {
  const context = React.useContext(AppStateContext)
  if (!context) {
    throw new Error('useAppState must be used within the AppProvider')
  }
  return context
}

function useAppDispatch() {
  const context = React.useContext(AppDispatchContext)
  if (!context) {
    throw new Error('useAppDispatch must be used within the AppProvider')
  }
  return context
}

function Grid() {
  const dispatch = useAppDispatch()
  const [rows, setRows] = useDebouncedState(50)
  const [columns, setColumns] = useDebouncedState(50)
  const updateGridData = () => dispatch({type: 'UPDATE_GRID'})
  return (
    <AppGrid
      onUpdateGrid={updateGridData}
      rows={rows}
      handleRowsChange={setRows}
      columns={columns}
      handleColumnsChange={setColumns}
      Cell={Cell}
    />
  )
}
Grid = React.memo(Grid)

function Cell({row, column}) {
  const state = useAppState()
  const cell = state.grid[row][column]
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
  return (
    <button
      className="cell"
      onClick={handleClick}
      style={{
        color: cell > 50 ? 'white' : 'black',
        backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
      }}
    >
      {Math.floor(cell)}
    </button>
  )
}
Cell = React.memo(Cell)

function DogNameInput() {
  const state = useAppState()
  const dispatch = useAppDispatch()
  const {dogName} = state

  function handleChange(event) {
    const newDogName = event.target.value
    dispatch({type: 'TYPED_IN_DOG_INPUT', dogName: newDogName})
  }

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label htmlFor="dogName">Dog Name</label>
      <input
        value={dogName}
        onChange={handleChange}
        id="dogName"
        placeholder="Toto"
      />
      {dogName ? (
        <div>
          <strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
        </div>
      ) : null}
    </form>
  )
}
function App() {
  const forceRerender = useForceRerender()
  return (
    <div className="grid-app">
      <button onClick={forceRerender}>force rerender</button>
      <AppProvider>
        <div>
          <DogNameInput />
          <Grid />
        </div>
      </AppProvider>
    </div>
  )
}

export default App

Bạn có thể tham khảo chi tiết về cách tối ưu context từ bài viết của Dan ở đây.

Nguồn: Epic React by Kent C.Dodds

Leave a Comment