Tự học ReactJS: React Patterns – Sử dụng state reducer

Ở hai bài trước chúng ta đã sử dụng React.useState để quản lí state (on/off) của Toggle. Nếu như bạn muốn quản lí những component phức tạp hơn như dropdown, autocomplete, table,… Bạn có thể thay thế React.useState bằng React.useReducer.

Khi chúng ta quản lí state bằng cách sử dụng React.useReducer thì được gọi là pattern state reducer.

Vi dụ về Toggle component được viết lại bằng state reducer như sau:

import * as React from 'react'
import {Switch} from '../switch'

const callAll =
  (...fns) =>
  (...args) =>
    fns.forEach(fn => fn?.(...args))

function toggleReducer(state, {type, initialState}) {
  switch (type) {
    case 'toggle': {
      return {on: !state.on}
    }
    case 'reset': {
      return initialState
    }
    default: {
      throw new Error(`Unsupported type: ${type}`)
    }
  }
}

function useToggle({initialOn = false} = {}) {
  const {current: initialState} = React.useRef({on: initialOn})
  const [state, dispatch] = React.useReducer(toggleReducer, initialState)
  const {on} = state

  const toggle = () => dispatch({type: 'toggle'})
  const reset = () => dispatch({type: 'reset', initialState})

  function getTogglerProps({onClick, ...props} = {}) {
    return {
      'aria-pressed': on,
      onClick: callAll(onClick, toggle),
      ...props,
    }
  }

  function getResetterProps({onClick, ...props} = {}) {
    return {
      onClick: callAll(onClick, reset),
      ...props,
    }
  }

  return {
    on,
    reset,
    toggle,
    getTogglerProps,
    getResetterProps,
  }
}

function App() {
  const {on, getTogglerProps} = useToggle()

  return (
    <div>
      <Switch
        {...getTogglerProps({
          on: on,
          onClick: () => console.log('toggle'),
        })}
      />
    </div>
  )
}

export default App

Chúng ta đã thay thế React.useState bằng:

const [state, dispatch] = React.useReducer(toggleReducer, initialState)

Một nhu cầu không thể thiếu cho những component có khả năng tái sử dụng là khả năng mở rộng và hỗ trợ nhiều trường hợp hơn. Khi component trở nên phức tạp hơn, nhận nhiều props, config hơn để đáp ứng nhiều tính năng từ người dùng, chúng ta sẽ sử dụng pattern state reducer.

Giả sử trong trường hợp của Toggle, người dùng cần một tính năng là reset, chúng ta có thể dễ dàng thêm vào toggleReducer function như trên.

Thư viện downshift của Kent C.Dodds đang sử dụng pattern này.

Toggle component hỗ trợ custom reducer

Một ngày đẹp trời, ví dụ bạn nhận được một yều câu nâng cấp Toggle component. Khi người dùng click quá nhiều vào Toggle (khoảng 7-8 lần gì đó), bạn sẽ không cho người dùng click nữa, và có một nút reset. Khi vượt quá số lần click, người dùng phải nhấn nút reset để có thể toggle button trở lại.

Xem demo tại đây.

Đáp án:
import * as React from 'react'
import {Switch} from '../switch'

const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))

function toggleReducer(state, {type, initialState}) {
  switch (type) {
    case 'toggle': {
      return {on: !state.on}
    }
    case 'reset': {
      return initialState
    }
    default: {
      throw new Error(`Unsupported type: ${type}`)
    }
  }
}

function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
  const {current: initialState} = React.useRef({on: initialOn})
  const [state, dispatch] = React.useReducer(reducer, initialState)
  const {on} = state

  const toggle = () => dispatch({type: 'toggle'})
  const reset = () => dispatch({type: 'reset', initialState})

  function getTogglerProps({onClick, ...props} = {}) {
    return {
      'aria-pressed': on,
      onClick: callAll(onClick, toggle),
      ...props,
    }
  }

  function getResetterProps({onClick, ...props} = {}) {
    return {
      onClick: callAll(onClick, reset),
      ...props,
    }
  }

  return {
    on,
    reset,
    toggle,
    getTogglerProps,
    getResetterProps,
  }
}

function App() {
  const [timesClicked, setTimesClicked] = React.useState(0)
  const clickedTooMuch = timesClicked >= 4

  function toggleStateReducer(state, action) {
    switch (action.type) {
      case 'toggle': {
        if (clickedTooMuch) {
          return {on: state.on}
        }
        return {on: !state.on}
      }
      case 'reset': {
        return {on: false}
      }
      default: {
        throw new Error(`Unsupported type: ${action.type}`)
      }
    }
  }

  const {on, getTogglerProps, getResetterProps} = useToggle({
    reducer: toggleStateReducer,
  })

  return (
    <div>
      <Switch
        {...getTogglerProps({
          disabled: clickedTooMuch,
          on: on,
          onClick: () => setTimesClicked(count => count + 1),
        })}
      />
      {clickedTooMuch ? (
        <div data-testid="notice">
          Whoa, you clicked too much!
          <br />
        </div>
      ) : timesClicked > 0 ? (
        <div data-testid="click-count">Click count: {timesClicked}</div>
      ) : null}
      <button {...getResetterProps({onClick: () => setTimesClicked(0)})}>
        Reset
      </button>
    </div>
  )
}

export default App

Hiện tại hook useToggle cho phép chúng ta lấy getTogglerProps, getResetterProps (props getters) và được truyền vào custom reducer. Bên trong custom reducer, bạn có thể tự do xử lý state của Toggle. Bạn có thể kiểm tra biến clickedTooMuch và xử lí trong trường hợp click quá nhiều.

Nhận default reducer

useToggle hiện tại khá khó sử dụng bởi vì chưa có default reducer. Nó bắt buộc chúng ta phải truyền custom reducer. Trong trường hợp thông thường, tôi không cần xử lí gì đặc biệt thì tôi sẽ sử dụng default reducer như sau:

import * as React from 'react'
import {Switch} from '../switch'

const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))

function toggleReducer(state, {type, initialState}) {
  switch (type) {
    case 'toggle': {
      return {on: !state.on}
    }
    case 'reset': {
      return initialState
    }
    default: {
      throw new Error(`Unsupported type: ${type}`)
    }
  }
}

function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
  const {current: initialState} = React.useRef({on: initialOn})
  const [state, dispatch] = React.useReducer(reducer, initialState)
  const {on} = state

  const toggle = () => dispatch({type: 'toggle'})
  const reset = () => dispatch({type: 'reset', initialState})
  function getTogglerProps({onClick, ...props} = {}) {
    return {
      'aria-pressed': on,
      onClick: callAll(onClick, toggle),
      ...props,
    }
  }

  function getResetterProps({onClick, ...props} = {}) {
    return {
      onClick: callAll(onClick, reset),
      ...props,
    }
  }

  return {
    on,
    reset,
    toggle,
    getTogglerProps,
    getResetterProps,
  }
}
// export {useToggle, toggleReducer}

// import {useToggle, toggleReducer} from './use-toggle'

function App() {
  const [timesClicked, setTimesClicked] = React.useState(0)
  const clickedTooMuch = timesClicked >= 4

  function toggleStateReducer(state, action) {
    if (action.type === 'toggle' && clickedTooMuch) {
      return {on: state.on}
    }
    return toggleReducer(state, action)
  }

  const {on, getTogglerProps, getResetterProps} = useToggle({
    reducer: toggleStateReducer,
  })

  return (
    <div>
      <Switch
        {...getTogglerProps({
          disabled: clickedTooMuch,
          on: on,
          onClick: () => setTimesClicked(count => count + 1),
        })}
      />
      {clickedTooMuch ? (
        <div data-testid="notice">
          Whoa, you clicked too much!
          <br />
        </div>
      ) : timesClicked > 0 ? (
        <div data-testid="click-count">Click count: {timesClicked}</div>
      ) : null}
      <button {...getResetterProps({onClick: () => setTimesClicked(0)})}>
        Reset
      </button>
    </div>
  )
}

export default App

Trong file use-toggle.js chúng ta export ra export {useToggle, toggleReducer} để trong App component chúng ta import như sau:

import {useToggle, toggleReducer} from './use-toggle'

Sau đó chúng ta đơn giản thay đổi custom reducer như sau:

function toggleStateReducer(state, action) {
    if (action.type === 'toggle' && clickedTooMuch) {
      return {on: state.on}
    }
    return toggleReducer(state, action)
}

Chúng ta kiểm tra điều kiện clickedTooMuch và trả về một state cố định. Tất cả trường hợp còn lại chúng ta return về toggleReducer(state, action)

Thêm actionTypes cho toggle reducer

Hiện tại trong hàm custom reducer, chúng ta phải hardcode để check action type: action.type === 'toggle'

Nhưng cách này hơi nguy hiểm vì nếu chúng ta thay đổi action type bên trong useToggle thì sau đó phải thay đổi theo ở hàm custom reducer. Cho nên chúng ta sẽ tạo một object actionTypes trong useToggle và export nó ra để custom reducer được xài ké. Khi mà chúng ta thay đổi bên trong useToggle thì cũng ko ảnh hưởng code bên ngoài.

Ví dụ chúng ta cài đặt useToggle lại như sau:

import * as React from 'react'
import {Switch} from '../switch'

const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))

const actionTypes = {
  toggle: 'toggle',
  reset: 'reset',
}

function toggleReducer(state, {type, initialState}) {
  switch (type) {
    case actionTypes.toggle: {
      return {on: !state.on}
    }
    case actionTypes.reset: {
      return initialState
    }
    default: {
      throw new Error(`Unsupported type: ${type}`)
    }
  }
}

function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
  const {current: initialState} = React.useRef({on: initialOn})
  const [state, dispatch] = React.useReducer(reducer, initialState)
  const {on} = state

  const toggle = () => dispatch({type: actionTypes.toggle})
  const reset = () => dispatch({type: actionTypes.reset, initialState})

  function getTogglerProps({onClick, ...props} = {}) {
    return {
      'aria-pressed': on,
      onClick: callAll(onClick, toggle),
      ...props,
    }
  }

  function getResetterProps({onClick, ...props} = {}) {
    return {
      onClick: callAll(onClick, reset),
      ...props,
    }
  }

  return {
    on,
    reset,
    toggle,
    getTogglerProps,
    getResetterProps,
  }
}
// export {useToggle, toggleReducer, actionTypes}

// import {useToggle, toggleReducer, actionTypes} from './use-toggle'

function App() {
  const [timesClicked, setTimesClicked] = React.useState(0)
  const clickedTooMuch = timesClicked >= 4

  function toggleStateReducer(state, action) {
    if (action.type === actionTypes.toggle && clickedTooMuch) {
      return {on: state.on}
    }
    return toggleReducer(state, action)
  }

  const {on, getTogglerProps, getResetterProps} = useToggle({
    reducer: toggleStateReducer,
  })

  return (
    <div>
      <Switch
        {...getTogglerProps({
          disabled: clickedTooMuch,
          on: on,
          onClick: () => setTimesClicked(count => count + 1),
        })}
      />
      {clickedTooMuch ? (
        <div data-testid="notice">
          Whoa, you clicked too much!
          <br />
        </div>
      ) : timesClicked > 0 ? (
        <div data-testid="click-count">Click count: {timesClicked}</div>
      ) : null}
      <button {...getResetterProps({onClick: () => setTimesClicked(0)})}>
        Reset
      </button>
    </div>
  )
}

export default App

Nguồn: Epic React by Kent C.Dodds

One Comment

Leave a Reply

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