Published on

React Component Design - Compound Component

Authors
  • avatar
    Name
    Alex Yu
    Twitter

假設今天我們有一個 React component Switch,它的 props 如下

Switch.props = {
  on: PropTypes.boolean,
  onClick: PropTypes.func,
};

請設計一個 component 叫Toggle,這個Toggle有一個state儲存on的狀態,on變化時要呼叫 props 傳下來的onTogglecallback,相信大多數人會實作類似以下的程式碼

import React from 'react';
import { Switch } from '../switch';

class Toggle extends React.Component {
  state = { on: false };
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => {
        this.props.onToggle(this.state.on);
      }
    );

  render() {
    const { on } = this.state;
    return <Switch on={on} onClick={this.toggle} />;
  }
}

使用Toggle的方式則類似這樣

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return <Toggle onToggle={onToggle} />;
}

這樣的設計看起來沒什麼問題,on state 保留在Toggle 裡面,並不需要透露給外面知道。

如果今天我們接到需求需要在onstate truefalse各顯示不同的字串,於是我們修改成

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

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => (
    // same codes
  )

  render() {
    const { isOnText, isOffText } = this.props
    const { on } = this.state
    return (
      <>
        {on ? isOnText : isOffText}
        <Switch on={on} onClick={this.toggle} />
      </>
    )
  }
}

這樣的方式有三個缺點:

  1. 顯示的順序寫死在 component 裡面,無法客製化順序或者樣式
  2. props 命名方式沒統一的情況下,無法讓人一眼就知道這 props 是什麼意思,要傳什麼內容進來,需要檢視TogglepropTypes才可以了解
  3. 如果今天在ontrue的時候有額外的 callback 要呼叫,需要再新增一次 props 給Toggle

Compound Component

這時候Compound Component的設計方式便派上用場,它的概念是

[讓你的 UI 元件透過  this.props.children  的方式傳入給  parent component,利用  React.Children.map()  來 render 所有傳入的  this.props.children,並且透過  React.cloneElement  將 parent 的  state  傳入每個 children 的  props,讓 parent 與 children 之間會  隱含著狀態的共享,對於元件使用者來說,他只需要傳入想要的  children component,不用知道 parent 與 children 之間如何溝通,當然也能隨意調整順序,這樣的 API 設計,對於元件使用者就非常的友善。

在這樣的原則下,不難發現,Compound component 必須要  同時結合使用 parent component 與 children component 才有意義。](https://blog.techbridge.cc/2018/06/27/advanced-react-component-patterns-note/)

實作後的結果如下

// Compound Components

import React from 'react';
import { Switch } from '../switch';

class Toggle extends React.Component {
  static On = ({ on, children }) => (on ? children : null);
  static Off = ({ on, children }) => (on ? null : children);
  static Button = ({ on, toggle, ...props }) => (
    <Switch on={on} onClick={toggle} {...props} />
  );

  state = { on: false };
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => this.props.onToggle(this.state.on)
    );

  render() {
    return React.Children.map(this.props.children, (child) =>
      React.cloneElement(child, {
        on: this.state.on,
        toggle: this.toggle,
      })
    );
  }
}

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return (
    <Toggle onToggle={onToggle}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <Toggle.Button />
    </Toggle>
  );
}

實作後的結果解決了上面提到的 3 個問題:

  1. 順序現在可以依照children的排序做顯示,樣式也可以自定義
  2. props 維持在最小限度,只有onToggle需要傳入
  3. Toggle內的children根據介面,OnOff可以接收到on state,Button則可以收到on state 跟toggle function。所以OnOff可以根據接收到的onstate 自行決定要觸發的 callback

More Flexible Compound Component

Check Children

在上面的實作中,我們的 Compound Components 只有辦法接受Toggle裡面定義的On,Off,Button children,這邊可以做檢查判斷 children 是否有在Toggle的定義內,如果 children 不在Toggle的 children 的定義內,則正常 render children

// Compound Components

import React from 'react';
import { Switch } from '../switch';

function componentHasChild(child) {
  for (const property in Toggle) {
    if (Toggle.hasOwnProperty(property)) {
      if (child.type === Toggle[property]) {
        return true;
      }
    }
  }
  return false;
}

class Toggle extends React.Component {
  static On = ({ on, children }) => (on ? children : null);
  static Off = ({ on, children }) => (on ? null : children);
  static Button = ({ on, toggle, ...props }) => (
    <Switch on={on} onClick={toggle} {...props} />
  );
  state = { on: false };
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => this.props.onToggle(this.state.on)
    );
  render() {
    return React.Children.map(this.props.children, (child) => {
      if (componentHasChild(child)) {
        return React.cloneElement(child, {
          on: this.state.on,
          toggle: this.toggle,
        });
      }
      return child;
    });
  }
}

const Hi = () => <h4>Hi</h4>;
function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return (
    <Toggle onToggle={onToggle}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <Toggle.Button />
      <Hi />
    </Toggle>
  );
}

Using Context

現在我們的Toggle元件只有辦法 render 出第一層的 children,當我們想要把onstate 跟toggle傳給 children 不管層數有幾層時,可以使用Reactcontext API。

// Flexible Compound Components with context

import React from 'react';
import { Switch } from '../switch';

const ToggleContext = React.createContext();

class Toggle extends React.Component {
  static On = ({ children }) => (
    <ToggleContext.Consumer>
      {({ on }) => (on ? children : null)}
    </ToggleContext.Consumer>
  );
  static Off = ({ children }) => (
    <ToggleContext.Consumer>
      {({ on }) => (on ? null : children)}
    </ToggleContext.Consumer>
  );
  static Button = (props) => (
    <ToggleContext.Consumer>
      {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />}
    </ToggleContext.Consumer>
  );
  state = { on: false };
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => this.props.onToggle(this.state.on)
    );

  render() {
    // 由於不用傳遞 props 給 children,也就不用 React.Children.map 了,直接使用 this.props.children 即可
    return (
      <ToggleContext.Provider
        value={{ on: this.state.on, toggle: this.toggle }}
      >
        {this.props.children}
      </ToggleContext.Provider>
    );
  }
}

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return (
    <Toggle onToggle={onToggle}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <div>
        <Toggle.Button />
      </div>
    </Toggle>
  );
}

這邊我們可以增加Toggle的 children validation,避免On, Off, ButtonToggle以外的地方被使用

// Flexible Compound Components with context (extra credit 1)
// This adds validation to the consumer

import React from 'react';
import { Switch } from '../switch';

const ToggleContext = React.createContext();

function ToggleConsumer(props) {
  return (
    <ToggleContext.Consumer {...props}>
      {(context) => {
        if (!context) {
          throw new Error(
            `Toggle compound components cannot be rendered outside the Toggle component`
          );
        }
        return props.children(context);
      }}
    </ToggleContext.Consumer>
  );
}

class Toggle extends React.Component {
  static On = ({ children }) => (
    <ToggleConsumer>{({ on }) => (on ? children : null)}</ToggleConsumer>
  );
  static Off = ({ children }) => (
    <ToggleConsumer>{({ on }) => (on ? null : children)}</ToggleConsumer>
  );
  static Button = (props) => (
    <ToggleConsumer>
      {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />}
    </ToggleConsumer>
  );
  state = { on: false };
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => this.props.onToggle(this.state.on)
    );
  render() {
    return (
      <ToggleContext.Provider
        value={{ on: this.state.on, toggle: this.toggle }}
      >
        {this.props.children}
      </ToggleContext.Provider>
    );
  }
}

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return (
    <Toggle onToggle={onToggle}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <div>
        <Toggle.Button />
      </div>
    </Toggle>
  );
}

在使用 Provider/Context 的時候,要特別注意 re-render 的問題,這邊可以改良一下把 Provider 的value改以state傳入

// Flexible Compound Components with context
// This allows you to avoid unecessary rerenders

import React from 'react';
import { Switch } from '../switch';

const ToggleContext = React.createContext();

function ToggleConsumer(props) {
  return (
    <ToggleContext.Consumer {...props}>
      {(context) => {
        if (!context) {
          throw new Error(
            `Toggle compound components cannot be rendered outside the Toggle component`
          );
        }
        return props.children(context);
      }}
    </ToggleContext.Consumer>
  );
}

class Toggle extends React.Component {
  static On = ({ children }) => (
    <ToggleConsumer>{({ on }) => (on ? children : null)}</ToggleConsumer>
  );
  static Off = ({ children }) => (
    <ToggleConsumer>{({ on }) => (on ? null : children)}</ToggleConsumer>
  );
  static Button = (props) => (
    <ToggleConsumer>
      {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />}
    </ToggleConsumer>
  );
  // The reason we had to move `toggle` above `state` is because
  // in our `state` initialization we're _using_ `this.toggle`. So
  // if `this.toggle` is not defined before state is initialized, then
  // `state.toggle` will be undefined.
  toggle = () =>
    this.setState(
      ({ on }) => ({ on: !on }),
      () => this.props.onToggle(this.state.on)
    );
  state = { on: false, toggle: this.toggle };
  render() {
    return (
      <ToggleContext.Provider value={this.state}>
        {this.props.children}
      </ToggleContext.Provider>
    );
  }
}

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) {
  return (
    <Toggle onToggle={onToggle}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <div>
        <Toggle.Button />
      </div>
    </Toggle>
  );
}

結論

最後來對Compound Component做總結,這樣的設計方式能讓 parent component 開放一定程度的介面給 children component 使用,使得 parent component 跟 children component 使用上有一定程度的語意關聯,render 的順序也可以保持彈性。但相反的,同時也綁死 children component 只能在 parent component 做使用。

如果要對 children component 更有彈性,比如檢查 children component 是否有在 parent component 的介面內,或者讓 children component 有多級層數,都需要更加複雜的檢查以及 props 傳遞的方式(Provider/Context)。

Related articles