- Published on
React Component Design - Compound Component
- Authors

- Name
- Alex Yu
假設今天我們有一個 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 true跟false各顯示不同的字串,於是我們修改成
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} />
</>
)
}
}
這樣的方式有三個缺點:
- 顯示的順序寫死在 component 裡面,無法客製化順序或者樣式
- props 命名方式沒統一的情況下,無法讓人一眼就知道這 props 是什麼意思,要傳什麼內容進來,需要檢視
Toggle的propTypes才可以了解 - 如果今天在
on是true的時候有額外的 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 個問題:
- 順序現在可以依照
children的排序做顯示,樣式也可以自定義 - props 維持在最小限度,只有
onToggle需要傳入 Toggle內的children根據介面,On跟Off可以接收到onstate,Button則可以收到onstate 跟togglefunction。所以On跟Off可以根據接收到的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 不管層數有幾層時,可以使用React的context 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, Button在Toggle以外的地方被使用
// 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
- https://youtu.be/hEGg-3pIHlE
- https://plainenglish.io/blog/5-advanced-react-patterns-a6b7624267a6
- https://frontendmasters.com/courses/advanced-react-patterns/
- https://blog.techbridge.cc/2018/06/27/advanced-react-component-patterns-note/
- https://blog.techbridge.cc/2018/07/21/advanced-react-component-patterns-note-II/
- https://kentcdodds.com/blog/answers-to-common-questions-about-render-props
- https://youtu.be/BcVAq3YFiuc
- https://codesandbox.io/s/github/kentcdodds/advanced-react-patterns-v2
