Tự học ReactJS: React Patterns – Compound components

Định nghĩa

Khi chúng ta kết hợp nhiều components với nhau tạo thành một component hoàn chỉnh được gọi là compound component. Một ví dụ kinh điển là <select><option> trong HTML:

<select>
  <option value="1">Option 1</option>
  <option value="2">Option 2</option>
</select>

<select> có nhiệm vụ quản lí state (option nào được nào, và active option nào), còn <option> thì đảm nhận trách nhiệm hiển thị những tuỳ chọn bên trong <select>

Giả sử bạn muốn cài đặt một custom select trong React như sau:

<CustomSelect
  options={[
    {value: '1', display: 'Option 1'},
    {value: '2', display: 'Option 2'},
  ]}
/>

Component này có thể tái sử dụng ở nhiều page nhưng nó sẽ không dễ custom và không linh hoạt trong một số trường hợp mà chúng ta muốn thay đổi display của từng option bên trong hay muốn hiển thị option được chọn. Nhưng với compound component trong trường hợp này sẽ dễ sử dụng lại một cách linh hoạt <CustomSelect /> hơn.

Một số thư viện sử dụng pattern này là:

Cài đặt Toggle component

Mục tiêu chúng ta sẽ build một reusable component <Toggle /> như đây.

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

function Toggle() {
  const [on, setOn] = React.useState(false)
  const toggle = () => setOn(!on)

  // Bạn hãy sử dụng React.Children để truyền props: on, toggle cho từng children
  // 💰 React.Children.map(props.children, child => {/* return child clone here */})
  // 📜 https://reactjs.org/docs/react-api.html#reactchildren
  // 📜 https://reactjs.org/docs/react-api.html#cloneelement
  return <Switch on={on} onClick={toggle} />
}

// 🐨 Thêm cài đặt cho những thông tin sau

// Nhận props `on` và `children` và trả về `children` nếu `on` là true
const ToggleOn = () => null

// Nhận props `on` và `children` và trả về `children` nếu `on` là false
const ToggleOff = () => null

//Nhận props `on` và `toggle` và trả về <Switch /> với nhứng props đó.
const ToggleButton = () => null

function App() {
  return (
    <div>
      <Toggle>
        <ToggleOn>The button is on</ToggleOn>
        <ToggleOff>The button is off</ToggleOff>
        <ToggleButton />
      </Toggle>
    </div>
  )
}

export default App

Đáp án:

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

function Toggle({children}) {
  const [on, setOn] = React.useState(false)
  const toggle = () => setOn(!on)
  return React.Children.map(children, child =>
    React.cloneElement(child, {on, toggle}),
  )
}

function ToggleOn({on, children}) {
  return on ? children : null
}

function ToggleOff({on, children}) {
  return on ? null : children
}

function ToggleButton({on, toggle, ...props}) {
  return <Switch on={on} onClick={toggle} {...props} />
}

function App() {
  return (
    <div>
      <Toggle>
        <ToggleOn>The button is on</ToggleOn>
        <ToggleOff>The button is off</ToggleOff>
        <ToggleButton />
      </Toggle>
    </div>
  )
}

export default App

Như bạn thấy rằng bạn có thể truyền bất kỳ component con nào trong <Toggle> như <ToggleOn>, <ToggleOff> để custom hiển thị trạng thái của component Toggle. Nó rất dễ sử dụng và rất linh hoạt.

Nhận children là DOM

Giả sử chúng ta muốn chèn DOM vào trong Toggle như sau:

<Toggle>
   <ToggleOn>The button is on</ToggleOn>
   <ToggleOff>The button is off</ToggleOff>
   <span>Click để bật/tắt chức năng dark mode</span>
   <ToggleButton />
</Toggle>

Ví dụ bạn muốn thêm vào một thẻ span để chú thích thêm cho Toggle chẳng hạn, thì bạn phải thay đổi code như thế nào?

Chúng ta có thể check children type như sau:

function Toggle({children}) {
  const [on, setOn] = React.useState(false)
  const toggle = () => setOn(!on)
  return React.Children.map(children, child => {
    return typeof child.type === 'string'
      ? child
      : React.cloneElement(child, {on, toggle})
  })
}

Sử dụng context

Hiện tại thì Toggle hoạt động tương đối tốt, nhưng có một ca là khi chúng ta lồng thêm thẻ div bên ngoài <ToggleButton /> chẳng hạn thì cách ở trên sẽ không được

Thư viện Reach UI cũng dùng pattern này Reach Accordion.

Ví dụ chúng ta xài như sau:

<Toggle>
    <ToggleOn>The button is on</ToggleOn>
    <ToggleOff>The button is off</ToggleOff>
    <div>
      <ToggleButton />
    </div>
</Toggle>

Chạy vòng lặp thể truyền props cho từng con trong ca này thì ko được, <ToggleButton /> sẽ không nhận được state on và method toggle.

Trong trường hợp này, chúng ta có thể sử dụng React.createContext để share state của Toggle cho từng con của nó. Tôi thấy cách này khá hay và linh hoạt 🙂

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

const ToggleContext = React.createContext()
ToggleContext.displayName = 'ToggleContext'

function Toggle({children}) {
  const [on, setOn] = React.useState(false)
  const toggle = () => setOn(!on)

  return (
    <ToggleContext.Provider value={{on, toggle}}>
      {children}
    </ToggleContext.Provider>
  )
}

function useToggle() {
  return React.useContext(ToggleContext)
}

function ToggleOn({children}) {
  const {on} = useToggle()
  return on ? children : null
}

function ToggleOff({children}) {
  const {on} = useToggle()
  return on ? null : children
}

function ToggleButton({...props}) {
  const {on, toggle} = useToggle()
  return <Switch on={on} onClick={toggle} {...props} />
}

function App() {
  return (
    <div>
      <Toggle>
        <ToggleOn>The button is on</ToggleOn>
        <ToggleOff>The button is off</ToggleOff>
        <div>
          <ToggleButton />
        </div>
      </Toggle>
    </div>
  )
}

export default App

Với cách này chúng ta thoải mái để các con của Toggle nằm bên trong div nào cũng được.

Nguồn: Epic React by Kent C.Dodds

2 Comments

Leave a Reply

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