building-compound-components
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuilding 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.tsxmy-component/
├── index.tsx # Namespace export
├── root/
│ ├── component-root.tsx
│ └── component-context.tsx
├── item/
│ └── component-item.tsx
└── content/
└── component-content.tsxContext 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 ,
ComponentPropstypesRenderProps
- 基础组件中不包含样式 - 使用者通过className/属性控制所有样式
- 使用Context共享状态 - 父组件管理状态,子组件消费状态
- 使用数据属性支持CSS - 暴露这类状态
data-state="open" - 支持asChild - 允许使用者替换底层元素
- 转发Ref - 始终使用forwardRef
- 设置显示名称 - 为React DevTools设置名称(如、
Component.Root)Component.Item - 缺失Context时抛出错误 - 快速失败并给出清晰的错误信息
- 导出类型 - 使用者需要、
ComponentProps等类型RenderProps
When to Use Each Pattern
各模式适用场景
| Scenario | Pattern | Why |
|---|---|---|
| Static content | Direct children | Simplest, most flexible |
| Need internal state | Render prop | Explicit state access |
| List/iteration | Sub-context | Each item gets own context |
| Element polymorphism | asChild | Change underlying element |
| CSS-only styling | Data attributes | No 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缺失时抛出错误
- 渲染属性类型中使用内联函数 - 定义规范的接口
- 默认导出 - 在命名空间对象中使用命名导出