building-compound-components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Building Compound Components

构建复合组件

Create unstyled, composable React components following the Radix UI / Base UI pattern. Components expose behavior via context while consumers control rendering.
遵循Radix UI / Base UI模式创建无样式、可组合的React组件。组件通过Context暴露行为,而由使用者控制渲染。

Quick Start

快速开始

tsx
// 1. Create context for shared state
const StepsContext = React.createContext<StepsContextValue | null>(null);

// 2. Create Root that provides context
const StepsRoot = ({ children, className, ...props }) => {
  const [steps] = useState(["Step 1", "Step 2"]);
  return (
    <StepsContext.Provider value={{ steps }}>
      <div className={className} {...props}>
        {children}
      </div>
    </StepsContext.Provider>
  );
};

// 3. Create consumer components
const StepsItem = ({ children, className, ...props }) => {
  const { steps } = useStepsContext();
  return (
    <div className={className} {...props}>
      {children}
    </div>
  );
};

// 4. Export as namespace
export const Steps = {
  Root: StepsRoot,
  Item: StepsItem,
};
tsx
// 1. Create context for shared state
const StepsContext = React.createContext<StepsContextValue | null>(null);

// 2. Create Root that provides context
const StepsRoot = ({ children, className, ...props }) => {
  const [steps] = useState(["Step 1", "Step 2"]);
  return (
    <StepsContext.Provider value={{ steps }}>
      <div className={className} {...props}>
        {children}
      </div>
    </StepsContext.Provider>
  );
};

// 3. Create consumer components
const StepsItem = ({ children, className, ...props }) => {
  const { steps } = useStepsContext();
  return (
    <div className={className} {...props}>
      {children}
    </div>
  );
};

// 4. Export as namespace
export const Steps = {
  Root: StepsRoot,
  Item: StepsItem,
};

Core Pattern

核心模式

File Structure

文件结构

my-component/
├── index.tsx              # Namespace export
├── root/
│   ├── component-root.tsx
│   └── component-context.tsx
├── item/
│   └── component-item.tsx
└── content/
    └── component-content.tsx
my-component/
├── index.tsx              # Namespace export
├── root/
│   ├── component-root.tsx
│   └── component-context.tsx
├── item/
│   └── component-item.tsx
└── content/
    └── component-content.tsx

Context Pattern

Context模式

tsx
// component-context.tsx
import * as React from "react";

interface ComponentContextValue {
  data: unknown;
  isOpen: boolean;
  toggle: () => void;
}

const ComponentContext = React.createContext<ComponentContextValue | null>(
  null,
);

export function useComponentContext() {
  const context = React.useContext(ComponentContext);
  if (!context) {
    throw new Error("Component parts must be used within Component.Root");
  }
  return context;
}

export { ComponentContext };
tsx
// component-context.tsx
import * as React from "react";

interface ComponentContextValue {
  data: unknown;
  isOpen: boolean;
  toggle: () => void;
}

const ComponentContext = React.createContext<ComponentContextValue | null>(
  null,
);

export function useComponentContext() {
  const context = React.useContext(ComponentContext);
  if (!context) {
    throw new Error("Component parts must be used within Component.Root");
  }
  return context;
}

export { ComponentContext };

Root Component

根组件

tsx
// component-root.tsx
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { ComponentContext } from "./component-context";

interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
  asChild?: boolean;
  defaultOpen?: boolean;
}

export const ComponentRoot = React.forwardRef<
  HTMLDivElement,
  ComponentRootProps
>(({ asChild, defaultOpen = false, children, ...props }, ref) => {
  const [isOpen, setIsOpen] = React.useState(defaultOpen);
  const Comp = asChild ? Slot : "div";

  return (
    <ComponentContext.Provider
      value={{ isOpen, toggle: () => setIsOpen(!isOpen) }}
    >
      <Comp ref={ref} data-state={isOpen ? "open" : "closed"} {...props}>
        {children}
      </Comp>
    </ComponentContext.Provider>
  );
});
ComponentRoot.displayName = "Component.Root";
tsx
// component-root.tsx
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { ComponentContext } from "./component-context";

interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
  asChild?: boolean;
  defaultOpen?: boolean;
}

export const ComponentRoot = React.forwardRef<
  HTMLDivElement,
  ComponentRootProps
>(({ asChild, defaultOpen = false, children, ...props }, ref) => {
  const [isOpen, setIsOpen] = React.useState(defaultOpen);
  const Comp = asChild ? Slot : "div";

  return (
    <ComponentContext.Provider
      value={{ isOpen, toggle: () => setIsOpen(!isOpen) }}
    >
      <Comp ref={ref} data-state={isOpen ? "open" : "closed"} {...props}>
        {children}
      </Comp>
    </ComponentContext.Provider>
  );
});
ComponentRoot.displayName = "Component.Root";

Namespace Export

命名空间导出

tsx
// index.tsx
import { ComponentRoot } from "./root/component-root";
import { ComponentTrigger } from "./trigger/component-trigger";
import { ComponentContent } from "./content/component-content";

export const Component = {
  Root: ComponentRoot,
  Trigger: ComponentTrigger,
  Content: ComponentContent,
};

// Re-export types
export type { ComponentRootProps } from "./root/component-root";
export type { ComponentContentProps } from "./content/component-content";
tsx
// index.tsx
import { ComponentRoot } from "./root/component-root";
import { ComponentTrigger } from "./trigger/component-trigger";
import { ComponentContent } from "./content/component-content";

export const Component = {
  Root: ComponentRoot,
  Trigger: ComponentTrigger,
  Content: ComponentContent,
};

// Re-export types
export type { ComponentRootProps } from "./root/component-root";
export type { ComponentContentProps } from "./content/component-content";

Composition Patterns

组合模式

Pattern 1: Direct Children (Simplest)

模式1:直接子元素(最简单)

Best for static content. Consumer just adds children.
tsx
// Component
const Content = ({ children, className, ...props }) => {
  const { data } = useContext();
  return (
    <div className={className} {...props}>
      {children}
    </div>
  );
};

// Usage
<Component.Content className="my-styles">
  <p>Static content here</p>
</Component.Content>;
最适合静态内容。使用者只需添加子元素即可。
tsx
// Component
const Content = ({ children, className, ...props }) => {
  const { data } = useContext();
  return (
    <div className={className} {...props}>
      {children}
    </div>
  );
};

// Usage
<Component.Content className="my-styles">
  <p>Static content here</p>
</Component.Content>;

Pattern 2: Render Prop (State Access)

模式2:Render Prop(状态访问)

Best when consumer needs internal state.
tsx
// Component
interface ContentProps {
  render?: (props: { data: string; isLoading: boolean }) => React.ReactNode;
  children?: React.ReactNode;
}

const Content = ({ render, children, ...props }) => {
  const { data, isLoading } = useContext();

  const content = render ? render({ data, isLoading }) : children;
  return <div {...props}>{content}</div>;
};

// Usage
<Component.Content
  render={({ data, isLoading }) => (
    <div className={isLoading ? "opacity-50" : ""}>{data}</div>
  )}
/>;
最适合使用者需要访问内部状态的场景。
tsx
// Component
interface ContentProps {
  render?: (props: { data: string; isLoading: boolean }) => React.ReactNode;
  children?: React.ReactNode;
}

const Content = ({ render, children, ...props }) => {
  const { data, isLoading } = useContext();

  const content = render ? render({ data, isLoading }) : children;
  return <div {...props}>{content}</div>;
};

// Usage
<Component.Content
  render={({ data, isLoading }) => (
    <div className={isLoading ? "opacity-50" : ""}>{data}</div>
  )}
/>;

Pattern 3: Sub-Context (Maximum Composability)

模式3:子Context(最高可组合性)

Best for lists/iterations where each item needs its own context.
tsx
// Parent provides array context
const Steps = ({ children }) => {
  const { reasoning } = useMessageContext();
  return (
    <StepsContext.Provider value={{ steps: reasoning }}>
      {children}
    </StepsContext.Provider>
  );
};

// Item provides individual step context
const Step = ({ children, index }) => {
  const { steps } = useStepsContext();
  return (
    <StepContext.Provider value={{ step: steps[index], index }}>
      {children}
    </StepContext.Provider>
  );
};

// Content reads from nearest context
const StepContent = ({ className }) => {
  const { step } = useStepContext();
  return <div className={className}>{step}</div>;
};

// Usage - maximum flexibility
<ReasoningInfo.Steps className="space-y-4">
  {steps.map((_, i) => (
    <ReasoningInfo.Step key={i} index={i}>
      <div className="custom-wrapper">
        <ReasoningInfo.StepContent className="text-sm" />
      </div>
    </ReasoningInfo.Step>
  ))}
</ReasoningInfo.Steps>;
最适合列表/迭代场景,其中每个项都需要自己的Context。
tsx
// Parent provides array context
const Steps = ({ children }) => {
  const { reasoning } = useMessageContext();
  return (
    <StepsContext.Provider value={{ steps: reasoning }}>
      {children}
    </StepsContext.Provider>
  );
};

// Item provides individual step context
const Step = ({ children, index }) => {
  const { steps } = useStepsContext();
  return (
    <StepContext.Provider value={{ step: steps[index], index }}>
      {children}
    </StepContext.Provider>
  );
};

// Content reads from nearest context
const StepContent = ({ className }) => {
  const { step } = useStepContext();
  return <div className={className}>{step}</div>;
};

// Usage - maximum flexibility
<ReasoningInfo.Steps className="space-y-4">
  {steps.map((_, i) => (
    <ReasoningInfo.Step key={i} index={i}>
      <div className="custom-wrapper">
        <ReasoningInfo.StepContent className="text-sm" />
      </div>
    </ReasoningInfo.Step>
  ))}
</ReasoningInfo.Steps>;

Essential Features

核心特性

1. Data Attributes for CSS Styling

1. 用于CSS样式的数据属性

Expose state via data attributes so consumers can style with CSS only:
tsx
<div
  data-state={isOpen ? "open" : "closed"}
  data-disabled={disabled || undefined}
  data-loading={isLoading || undefined}
  data-slot="component-trigger"
  {...props}
>
CSS targeting:
css
[data-state="open"] {
  /* open styles */
}
[data-slot="component-trigger"]:hover {
  /* hover styles */
}
通过数据属性暴露状态,以便使用者仅用CSS即可实现样式:
tsx
<div
  data-state={isOpen ? "open" : "closed"}
  data-disabled={disabled || undefined}
  data-loading={isLoading || undefined}
  data-slot="component-trigger"
  {...props}
>
CSS目标选择:
css
[data-state="open"] {
  /* open styles */
}
[data-slot="component-trigger"]:hover {
  /* hover styles */
}

2. asChild Pattern (Radix Slot)

2. asChild模式(Radix Slot)

Allow consumers to replace the default element:
tsx
import { Slot } from "@radix-ui/react-slot";

interface Props extends React.HTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

const Trigger = ({ asChild, ...props }) => {
  const Comp = asChild ? Slot : "button";
  return <Comp {...props} />;
};

// Usage
<Component.Trigger asChild>
  <a href="/link">I'm a link now</a>
</Component.Trigger>;
允许使用者替换默认元素:
tsx
import { Slot } from "@radix-ui/react-slot";

interface Props extends React.HTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

const Trigger = ({ asChild, ...props }) => {
  const Comp = asChild ? Slot : "button";
  return <Comp {...props} />;
};

// Usage
<Component.Trigger asChild>
  <a href="/link">I'm a link now</a>
</Component.Trigger>;

3. Ref Forwarding

3. Ref转发

Always forward refs for DOM access:
tsx
export const Component = React.forwardRef<HTMLDivElement, Props>(
  (props, ref) => {
    return <div ref={ref} {...props} />;
  },
);
Component.displayName = "Component";
始终转发Ref以支持DOM访问:
tsx
export const Component = React.forwardRef<HTMLDivElement, Props>(
  (props, ref) => {
    return <div ref={ref} {...props} />;
  },
);
Component.displayName = "Component";

4. Proper TypeScript

4. 规范的TypeScript

Export prop types for consumers:
tsx
export interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
  asChild?: boolean;
  defaultOpen?: boolean;
}

export interface ComponentContentRenderProps {
  data: string;
  isLoading: boolean;
}
为使用者导出属性类型:
tsx
export interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
  asChild?: boolean;
  defaultOpen?: boolean;
}

export interface ComponentContentRenderProps {
  data: string;
  isLoading: boolean;
}

Guidelines

指导原则

  • No styles in primitives - consumers control all styling via className/props
  • Context for state sharing - parent manages, children consume
  • Data attributes for CSS - expose state like
    data-state="open"
  • Support asChild - let consumers swap the underlying element
  • Forward refs - always use forwardRef
  • Display names - set for React DevTools (
    Component.Root
    ,
    Component.Item
    )
  • Throw on missing context - fail fast with clear error messages
  • Export types - consumers need
    ComponentProps
    ,
    RenderProps
    types
  • 基础组件中不包含样式 - 使用者通过className/属性控制所有样式
  • 使用Context共享状态 - 父组件管理状态,子组件消费状态
  • 使用数据属性支持CSS - 暴露
    data-state="open"
    这类状态
  • 支持asChild - 允许使用者替换底层元素
  • 转发Ref - 始终使用forwardRef
  • 设置显示名称 - 为React DevTools设置名称(如
    Component.Root
    Component.Item
  • 缺失Context时抛出错误 - 快速失败并给出清晰的错误信息
  • 导出类型 - 使用者需要
    ComponentProps
    RenderProps
    等类型

When to Use Each Pattern

各模式适用场景

ScenarioPatternWhy
Static contentDirect childrenSimplest, most flexible
Need internal stateRender propExplicit state access
List/iterationSub-contextEach item gets own context
Element polymorphismasChildChange underlying element
CSS-only stylingData attributesNo JS needed for style variants
场景模式原因
静态内容直接子元素最简单、灵活性最高
需要访问内部状态Render Prop显式访问状态
列表/迭代场景子Context每个项拥有独立的Context
元素多态性asChild更改底层元素
纯CSS样式数据属性无需JS即可实现样式变体

Anti-Patterns

反模式

  • Hardcoded styles - primitives should be unstyled
  • Prop drilling - use context instead
  • Missing error boundaries - throw when context is missing
  • Inline functions in render prop types - define proper interfaces
  • Default exports - use named exports in namespace object
  • 硬编码样式 - 基础组件应该是无样式的
  • 属性透传 - 改用Context替代
  • 缺失错误边界 - 当Context缺失时抛出错误
  • 渲染属性类型中使用内联函数 - 定义规范的接口
  • 默认导出 - 在命名空间对象中使用命名导出