compound-pattern

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Compound Pattern

复合组件模式

In our application, we often have components that belong to each other. They're dependent on each other through the shared state, and share logic together. You often see this with components like
select
, dropdown components, or menu items. The compound component pattern allows you to create components that all work together to perform a task.
在我们的应用中,经常会有一些彼此关联的组件。它们通过共享状态相互依赖,并且共同分享逻辑。你经常会在
select
、下拉组件或菜单项这类组件中看到这种情况。复合组件模式允许你创建能够协同完成某项任务的组件。

When to Use

适用场景

  • Use this when building components like dropdowns, tabs, or menus with related sub-components
  • This is helpful when you want to provide a clean component API without exposing internal state management
  • 当你构建下拉菜单、标签页或包含相关子组件的菜单时,可使用该模式
  • 如果你希望提供简洁的组件API,同时不暴露内部状态管理逻辑,这种模式会很有帮助

Instructions

实现步骤

  • Use React Context API to share state between the parent compound component and its children
  • Attach child components as static properties on the parent (e.g.,
    FlyOut.Toggle
    ,
    FlyOut.List
    )
  • Memoize context values to avoid unnecessary re-renders in complex scenarios
  • Prefer the Context approach over
    React.Children.map
    for more flexible component nesting
  • 使用React Context API在父级复合组件及其子组件之间共享状态
  • 将子组件作为父组件的静态属性(例如:
    FlyOut.Toggle
    FlyOut.List
  • 在复杂场景下,对上下文值进行记忆化处理以避免不必要的重渲染
  • 相比
    React.Children.map
    ,更推荐使用Context方式,以支持更灵活的组件嵌套

Details

详细说明

Context API

Context API

Let's look at an example: we have a list of squirrel images! Besides just showing squirrel images, we want to add a button that makes it possible for the user to edit or delete the image. We can implement a
FlyOut
component that shows a list when the user toggles the component.
Within a
FlyOut
component, we essentially have three things:
  • The
    FlyOut
    wrapper, which contains the toggle button and the list
  • The
    Toggle
    button, which toggles the
    List
  • The
    List
    , which contains the list of menu items
Using the Compound component pattern with React's Context API is perfect for this example!
First, let's create the
FlyOut
component. This component keeps the state, and returns a
FlyOutProvider
with the value of the toggle to all the children it receives.
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}
We now have a stateful
FlyOut
component that can pass the value of
open
and
toggle
to its children!
Let's create the
Toggle
component. This component simply renders the component on which the user can click in order to toggle the menu.
js
function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}
In order to actually give
Toggle
access to the
FlyOutContext
provider, we need to render it as a child component of
FlyOut
! We can also make the
Toggle
component a property of the
FlyOut
component!
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

FlyOut.Toggle = Toggle;
This means that if we ever want to use the
FlyOut
component in any file, we only have to import
FlyOut
!
js
import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
    </FlyOut>
  );
}
Just a toggle is not enough. We also need to have a
List
with list items, which open and close based on the value of
open
.
js
function List({ children }) {
  const { open } = React.useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}
The
List
component renders its children based on whether the value of
open
is
true
or
false
. Let's make
List
and
Item
a property of the
FlyOut
component, just like we did with the
Toggle
component.
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

function List({ children }) {
  const { open } = useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}

FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
We can now use them as properties on the
FlyOut
component! In this case, we want to show two options to the user: Edit and Delete. Let's create a
FlyOut.List
that renders two
FlyOut.Item
components, one for the Edit option, and one for the Delete option.
js
import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}
Perfect! We just created an entire
FlyOut
component without adding any state in the
FlyOutMenu
itself!
The compound pattern is great when you're building a component library. You'll often see this pattern when using UI libraries like Semantic UI.
我们来看一个示例:我们有一组松鼠图片!除了展示图片,我们还想添加一个按钮,让用户可以编辑或删除图片。我们可以实现一个
FlyOut
组件,当用户切换时显示一个列表。
FlyOut
组件中,主要包含三部分:
  • FlyOut
    容器,包含切换按钮和列表
  • Toggle
    按钮,用于切换
    List
    的显示状态
  • List
    ,包含菜单项列表
使用React的Context API结合复合组件模式非常适合这个场景!
首先,我们创建
FlyOut
组件。该组件维护状态,并通过
FlyOutProvider
将切换状态值传递给所有子组件。
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}
现在我们有了一个有状态的
FlyOut
组件,可以将
open
toggle
的值传递给它的子组件!
接下来创建
Toggle
组件。这个组件渲染一个用户可以点击的元素,用于切换菜单的显示状态。
js
function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}
为了让
Toggle
能够访问
FlyOutContext
提供的内容,我们需要将它作为
FlyOut
的子组件渲染!我们还可以将
Toggle
组件设为
FlyOut
组件的一个属性!
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

FlyOut.Toggle = Toggle;
这意味着如果我们想在任何文件中使用
FlyOut
组件,只需要导入
FlyOut
即可!
js
import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
    </FlyOut>
  );
}
只有切换按钮还不够,我们还需要一个
List
组件,它会根据
open
的值显示或关闭菜单项。
js
function List({ children }) {
  const { open } = React.useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}
List
组件会根据
open
的值是否为
true
来渲染其子组件。让我们像处理
Toggle
组件一样,将
List
Item
设为
FlyOut
组件的属性。
js
const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

function List({ children }) {
  const { open } = useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}

FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
现在我们可以将它们作为
FlyOut
组件的属性来使用了!在这个例子中,我们想给用户展示两个选项:编辑删除。让我们创建一个
FlyOut.List
,里面渲染两个
FlyOut.Item
组件,分别对应编辑删除选项。
js
import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}
完美!我们刚刚创建了一个完整的
FlyOut
组件,而不需要在
FlyOutMenu
中管理任何状态!
复合组件模式非常适合构建组件库。你在使用像Semantic UI这样的UI库时,经常会看到这种模式。

React.Children.map

React.Children.map

We can also implement the Compound Component pattern by mapping over the children of the component. We can add the
open
and
toggle
properties to these elements, by cloning them with the additional props.
js
export function FlyOut(props) {
  const [open, toggle] = React.useState(false);

  return (
    <div>
      {React.Children.map(props.children, (child) =>
        React.cloneElement(child, { open, toggle })
      )}
    </div>
  );
}
All children components are cloned, and passed the value of
open
and
toggle
. Instead of having to use the Context API like in the previous example, we now have access to these two values through
props
.
我们也可以通过遍历组件的子元素来实现复合组件模式。我们可以通过克隆元素并添加额外的props,将
open
toggle
属性添加到这些元素中。
js
export function FlyOut(props) {
  const [open, toggle] = React.useState(false);

  return (
    <div>
      {React.Children.map(props.children, (child) =>
        React.cloneElement(child, { open, toggle })
      )}
    </div>
  );
}
所有子组件都会被克隆,并传递
open
toggle
的值。与之前的例子中使用Context API不同,现在我们可以通过
props
访问这两个值。

Pros

优点

Compound components manage their own internal state, which they share among the several child components. When implementing a compound component, we don't have to worry about managing the state ourselves.
When importing a compound component, we don't have to explicitly import the child components that are available on that component.
Note (React 18+): The compound component pattern using React's Context API remains a recommended pattern for related components that share state. The implementation using Hooks (
useState
,
useContext
) is modern and aligns with current best practices. When using context, avoid unnecessary re-renders by not re-creating context values each render. In complex scenarios, you might optimize by memoizing the context value or splitting context (e.g., a context for the
open
boolean and another for the
toggle
function). The pattern is fully compatible with React's upcoming features like Server Components — just ensure the context provider and consumers are all either server or client components as needed.
js
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}
复合组件管理自己的内部状态,并在多个子组件之间共享。当实现复合组件时,我们不需要自己管理状态。
当导入复合组件时,我们不需要显式导入该组件下可用的子组件。
注意(React 18+): 使用React Context API的复合组件模式仍然是推荐模式,适用于共享状态的关联组件。使用Hooks(
useState
useContext
)的实现是现代化的,符合当前的最佳实践。使用Context时,避免每次渲染都重新创建Context值,以防止不必要的重渲染。在复杂场景中,你可以通过记忆化Context值或拆分Context(例如,一个用于
open
布尔值的Context,另一个用于
toggle
函数的Context)来进行优化。该模式完全兼容React即将推出的功能,如Server Components——只需确保Context提供者和消费者要么都是服务器组件,要么都是客户端组件即可。
js
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}

Cons

缺点

When using the
React.Children.map
to provide the values, the component nesting is limited. Only direct children of the parent component will have access to the
open
and
toggle
props, meaning we can't wrap any of these components in another component.
js
export default function FlyoutMenu() {
  return (
    <FlyOut>
      {/* This breaks */}
      <div>
        <FlyOut.Toggle />
        <FlyOut.List>
          <FlyOut.Item>Edit</FlyOut.Item>
          <FlyOut.Item>Delete</FlyOut.Item>
        </FlyOut.List>
      </div>
    </FlyOut>
  );
}
Cloning an element with
React.cloneElement
performs a shallow merge. Already existing props will be merged together with the new props that we pass. This could end up in a naming collision, if an already existing prop has the same name as the props we're passing to the
React.cloneElement
method. As the props are shallowly merged, the value of that prop will be overwritten with the latest value that we pass.
当使用
React.Children.map
来传递值时,组件的嵌套会受到限制。只有父组件的直接子元素才能访问
open
toggle
props,这意味着我们不能将这些组件包裹在另一个组件中。
js
export default function FlyoutMenu() {
  return (
    <FlyOut>
      {/* 这样会失效 */}
      <div>
        <FlyOut.Toggle />
        <FlyOut.List>
          <FlyOut.Item>Edit</FlyOut.Item>
          <FlyOut.Item>Delete</FlyOut.Item>
        </FlyOut.List>
      </div>
    </FlyOut>
  );
}
使用
React.cloneElement
克隆元素时会进行浅合并。已有的props会与我们传递的新props合并。如果已有的props与我们传递给
React.cloneElement
方法的props同名,可能会导致命名冲突。由于props是浅合并的,该props的值会被我们传递的最新值覆盖。

Source

来源

References

参考资料