mcpField
R
react-composition-2026

react-composition-2026

github.com/patternsdev开发2571.1k 次安装28 周前更新
代码仓库

关于 react-composition-2026

面向 React 开发者的组件组合模式指南,适用于设计、重构和评审灵活可维护的组件 API。内容涵盖复合组件、显式变体、状态解耦等模式,也包括 React 19 的 ref 用法。

主要功能

  1. 01用组合替代布尔属性堆叠
  2. 02通过 Context 构建复合组件
  3. 03使用明确的变体组件
  4. 04以 children 进行组合
  5. 05将状态实现与 UI 解耦
  6. 06介绍多态、插槽和无头组件

适用场景

  1. 01重构难以扩展的组件
  2. 02设计可复用组件库 API
  3. 03评审布尔属性过多的组件
  4. 04让共享组件状态实现可替换

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/patternsdev/skills --skill react-composition-2026

安装后在 Claude Code 中输入/react-composition-2026确认已加载。

详细介绍

现代 React 组合模式

目录

用于构建灵活、易维护且可扩展的 React 组件的组合模式。这些模式以可组合、明确的设计取代布尔属性泛滥、僵化的组件 API 和混乱的状态。

适用场景

在以下情况下参考这些模式:

  • 组件有超过 3-4 个用于控制行为的布尔属性
  • 构建可复用的 UI 组件或共享组件库
  • 重构难以扩展的组件
  • 设计供其他团队使用的组件 API
  • 审查组件架构的灵活性和可维护性

说明

  • 在组件设计、代码生成和审查过程中应用这些模式。发现布尔属性不断累积或组件 API 僵化时,建议采用适当的组合模式。

详情

概览

核心原则:组合优于配置。不要通过添加布尔属性和条件分支来处理每种变体,而是将更小、更专注的组件组合起来。这样能让组件更易理解、测试和扩展——无论是对人类还是 AI Agent 都如此。


1. 用组合取代布尔属性

影响:高——防止组合爆炸,让意图更加明确。

布尔属性会成倍增加复杂性:4 个布尔值就有 16 种可能状态,其中大多数都未经测试。用可组合的子组件取代它们。

避免——布尔属性不断累积:

<Card
  showHeader
  showFooter
  collapsible
  bordered
  withShadow
  headerAction="close"
  size="large"
/>

推荐——明确组合:

<Card variant="bordered" shadow="md">
  <Card.Header>
    <h3>Title</h3>
    <Card.CloseButton />
  </Card.Header>
  <Card.Body collapsible>
    <p>Content here</p>
  </Card.Body>
  <Card.Footer>
    <Button>Save</Button>
  </Card.Footer>
</Card>

每个部分都明确、可测试,并且可以独立选择是否使用。


2. 使用 Context 构建复合组件

影响:高——共享隐式状态,无需逐层传递属性。

复合组件是一组协同工作的组件,它们通过 context 而非属性共享状态。父组件拥有状态,子组件读取状态。

避免——父组件通过属性管理所有内容:

<Select
  options={options}
  value={value}
  onChange={onChange}
  renderOption={(opt) => <span>{opt.icon} {opt.label}</span>}
  renderSelected={(opt) => <b>{opt.label}</b>}
  placeholder="Choose..."
  clearable
  searchable
  maxHeight={300}
/>

推荐——复合组件:

const SelectContext = createContext<SelectState | null>(null)

function Select({ children, value, onChange }: SelectProps) {
  const [open, setOpen] = useState(false)
  const ctx = useMemo(() => ({ value, onChange, open, setOpen }), [value, onChange, open])

  return (
    <SelectContext.Provider value={ctx}>
      <div className="select-root">{children}</div>
    </SelectContext.Provider>
  )
}

function Trigger({ children }: { children: React.ReactNode }) {
  const { open, setOpen } = useSelectContext()
  return <button onClick={() => setOpen(!open)}>{children}</button>
}

function Options({ children }: { children: React.ReactNode }) {
  const { open } = useSelectContext()
  if (!open) return null
  return <ul role="listbox">{children}</ul>
}

function Option({ value, children }: OptionProps) {
  const { value: selected, onChange, setOpen } = useSelectContext()
  return (
    <li
      role="option"
      aria-selected={value === selected}
      onClick={() => { onChange(value); setOpen(false) }}
    >
      {children}
    </li>
  )
}

Select.Trigger = Trigger
Select.Options = Options
Select.Option = Option

用法:

<Select value={color} onChange={setColor}>
  <Select.Trigger>Pick a color</Select.Trigger>
  <Select.Options>
    <Select.Option value="red">Red</Select.Option>
    <Select.Option value="blue">Blue</Select.Option>
  </Select.Options>
</Select>

3. 创建明确的变体组件

影响:中——让每种模式都成为清晰、专注的组件。

当组件有不同的“模式”(对话框与抽屉、内嵌与模态、卡片与列表项)时,应创建明确的变体组件,而不是通过属性切换。

避免——用一个组件和模式属性处理所有情况:

function MediaDisplay({ type, src, title, showControls, autoPlay, loop }: Props) {
  if (type === 'video') {
    return <video src={src} controls={showControls} autoPlay={autoPlay} loop={loop} />
  }
  if (type === 'audio') {
    return <audio src={src} controls={showControls} />
  }
  return <img src={src} alt={title} />
}

推荐——明确的变体:

function VideoPlayer({ src, controls, autoPlay, loop }: VideoProps) {
  return <video src={src} controls={controls} autoPlay={autoPlay} loop={loop} />
}

function AudioPlayer({ src, controls }: AudioProps) {
  return <audio src={src} controls={controls} />
}

function Image({ src, alt }: ImageProps) {
  return <img src={src} alt={alt} />
}

每个变体只包含所需的属性——没有不可能的状态,也没有未使用的属性。


4. 组合时优先使用 Children,而非 Render Props

影响:中——API 更简单,可读性更好。

在 Hooks 出现之前,Render Props(renderHeader、renderItem)不可或缺;但如今大多数情况下,使用 children 进行组合更加简洁。

避免——Render Props 不断累积:

<DataTable
  data={users}
  renderHeader={() => <h2>Users</h2>}
  renderRow={(user) => <UserRow user={user} />}
  renderEmpty={() => <EmptyState />}
  renderFooter={() => <Pagination />}
/>

推荐——使用 Children 组合:

<DataTable data={users}>
  <DataTable.Header>
    <h2>Users</h2>
  </DataTable.Header>
  <DataTable.Body>
    {users.map(user => <UserRow key={user.id} user={user} />)}
  </DataTable.Body>
  <DataTable.Empty>
    <EmptyState />
  </DataTable.Empty>
  <DataTable.Footer>
    <Pagination />
  </DataTable.Footer>
</DataTable>

仅在父组件需要向渲染器提供数据时使用 Render Props(例如虚拟列表项)。


5. 将状态实现与 UI 解耦

影响:中——更换状态管理方式,无需更改组件。

为状态结构(值、操作、元数据)定义通用接口,然后由 Provider 实现该接口。组件使用接口,而非具体实现。

定义接口:

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  isLoading: boolean
}

const CounterContext = createContext<CounterState | null>(null)

function useCounter() {
  const ctx = useContext(CounterContext)
  if (!ctx) throw new Error('useCounter must be used within a CounterProvider')
  return ctx
}

使用本地状态实现:

function LocalCounterProvider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0)
  const value = useMemo(() => ({
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    isLoading: false,
  }), [count])
  return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
}

切换为由 API 支持的状态实现,无需更改使用方:

function ApiCounterProvider({ children }: { children: React.ReactNode }) {
  const { data, mutate } = useSWR('/api/counter', fetcher)
  const value = useMemo(() => ({
    count: data?.count ?? 0,
    increment: () => mutate(patch('/api/counter', { delta: 1 })),
    decrement: () => mutate(patch('/api/counter', { delta: -1 })),
    isLoading: !data,
  }), [data, mutate])
  return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
}

使用 useCounter() 的组件无需更改。


6. 将状态提升到 Provider 组件

影响:中——无需逐层传递属性,即可实现兄弟组件间的通信。

当两个兄弟组件需要共享状态时,将状态提升到 Provider 中,而不是通过父组件逐层传递回调。

避免——父组件向兄弟组件逐层传递状态:

function Page() {
  const [selected, setSelected] = useState<string | null>(null)
  return (
    <div>
      <Sidebar selected={selected} onSelect={setSelected} />
      <Detail selected={selected} />
    </div>
  )
}

推荐——Provider 管理共享状态:

function SelectionProvider({ children }: { children: React.ReactNode }) {
  const [selected, setSelected] = useState<string | null>(null)
  return (
    <SelectionContext.Provider value={{ selected, setSelected }}>
      {children}
    </SelectionContext.Provider>
  )
}

function Page() {
  return (
    <SelectionProvider>
      <Sidebar />
      <Detail />
    </SelectionProvider>
  )
}

Sidebar 和 Detail 都直接使用 useSelection()。


7. 使用多态 as 属性实现灵活元素

影响:中 — 一个组件,可对应任意底层元素或组件。

as 属性模式允许使用者控制渲染的元素,同时保留组件的样式和行为。

type BoxProps<C extends React.ElementType = 'div'> = {
  as?: C
  children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<C>, 'as' | 'children'>

function Box<C extends React.ElementType = 'div'>({
  as,
  children,
  ...props
}: BoxProps<C>) {
  const Component = as || 'div'
  return <Component {...props}>{children}</Component>
}

用法:

<Box>Default div</Box>
<Box as="section">A section</Box>
<Box as="a" href="/about">A link</Box>
<Box as={Link} to="/about">Router link</Box>

8. React 19:移除 forwardRef,将 ref 用作属性

影响:中 — 组件定义更简单。

React 19 将 ref 作为常规属性传递。不再需要 forwardRef 包装器。

React 18(已弃用的模式):

const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
  return <input ref={ref} {...props} />
})

React 19:

function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}

同样,use() 可以读取 Promise 或 context,并且可以有条件地调用:

import { use } from 'react'

function Panel({ themePromise }: { themePromise: Promise<Theme> }) {
  const theme = use(themePromise)  // unwraps promise
  const user = use(UserContext)    // conditional context read
  return <div className={theme.bg}>{user.name}</div>
}

9. 布局组件的插槽模式

影响:中 — 无需渲染属性即可设置命名插入点。

对于包含多个内容区域的布局组件,请使用基于子元素类型检测或命名子组件的插槽模式。

function AppLayout({ children }: { children: React.ReactNode }) {
  const slots = React.Children.toArray(children)
  const header = slots.find(
    (child): child is React.ReactElement => React.isValidElement(child) && child.type === AppLayout.Header
  )
  const content = slots.filter(
    (child) => !React.isValidElement(child) || child.type !== AppLayout.Header
  )

  return (
    <div className="app-layout">
      <header>{header}</header>
      <main>{content}</main>
    </div>
  )
}

AppLayout.Header = function Header({ children }: { children: React.ReactNode }) {
  return <>{children}</>
}

用法:

<AppLayout>
  <AppLayout.Header>
    <Logo />
    <Nav />
  </AppLayout.Header>
  <Dashboard />
</AppLayout>

10. 无头组件,实现最大灵活性

影响:高 — 只提供逻辑,不限定渲染方式。

无头组件提供行为(状态、键盘处理、ARIA 属性),但不包含任何标记。由使用者提供渲染方式。

function useToggle(initial = false) {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn(o => !o), [])
  const buttonProps = {
    'aria-pressed': on,
    onClick: toggle,
    role: 'switch' as const,
  }
  return { on, toggle, buttonProps }
}

用法 — 使用者控制所有渲染:

function DarkModeSwitch() {
  const { on, buttonProps } = useToggle(false)
  return (
    <button {...buttonProps} className={on ? 'dark' : 'light'}>
      {on ? 'Dark' : 'Light'} Mode
    </button>
  )
}

Radix UI、Headless UI 和 React Aria 等库都采用这种模式。当你需要灵活设计时,优先选择它们,而不是完全预设样式的组件库。


来源

这些模式来自 patterns.dev — 为更广泛的 React 社区提供组合指导。

评论

支持 Markdown