---
name: building-compound-components
description: Creates unstyled compound components that separate business logic from styles. Use when building headless UI primitives, creating component libraries, implementing Radix-style namespaced components, or when the user mentions "compound components", "headless", "unstyled", "primitives", or "render props".
---
# Building Compound Components
Create unstyled, composable React components following the Radix UI / Base UI pattern. Components expose behavior via context while consumers control rendering.
## Project Rules
These rules are specific to this codebase and override general patterns.
### Hooks Are Internal
Hooks are implementation details, not public API. **Never export hooks from the index.**
```tsx
// index.tsx - CORRECT
export const Component = {
Root: ComponentRoot,
Content: ComponentContent,
};
export type { ComponentRootProps, ComponentContentRenderProps };
// index.tsx - WRONG
export { useComponentContext }; // Don't export hooks
```
Consumers access state via **render props**, not hooks. When styled wrappers in the **same package** need hook access, import directly from the source file:
```tsx
import { useComponentContext } from "../base/component/component-context";
```
### No Custom Data Fetching in Primitives
Base components can use `@tambo-ai/react` SDK hooks (components require Tambo provider anyway). **Custom data fetching logic** (combining sources, external providers) belongs in the styled layer.
```tsx
// OK - SDK hooks in primitive
const Root = ({ children }) => {
const { value, setValue, submit } = useTamboThreadInput();
const { isIdle, cancel } = useTamboThread();
return {children};
};
// WRONG - custom data fetching in primitive
const Textarea = ({ resourceProvider }) => {
const { data: mcpResources } = useTamboMcpResourceList(search);
const externalResources = useFetchExternal(resourceProvider);
const combined = [...mcpResources, ...externalResources];
return
{combined.map(...)}
;
};
```
### Pre-computed Props Arrays for Collections
When exposing collections via render props, **pre-compute all props in a memoized array** rather than providing a getter function.
```tsx
// AVOID - getter function pattern
const Items = ({ children }) => {
const { rawItems, selectedId, removeItem } = useContext();
const getItemProps = (index: number) => ({
/* new object every call */
});
return children({ items: rawItems, getItemProps });
};
// PREFERRED - pre-computed array
const Items = ({ children }) => {
const { rawItems, selectedId, removeItem } = useContext();
const items = React.useMemo(
() =>
rawItems.map((item, index) => ({
item,
index,
isSelected: selectedId === item.id,
onSelect: () => setSelectedId(item.id),
onRemove: () => removeItem(item.id),
})),
[rawItems, selectedId, removeItem],
);
return children({ items });
};
```
## Workflow
Copy this checklist and track progress:
```
Compound Component Progress:
- [ ] Step 1: Create context file
- [ ] Step 2: Create Root component
- [ ] Step 3: Create consumer components
- [ ] Step 4: Create namespace export (index.tsx)
- [ ] Step 5: Verify all guidelines met
```
### Step 1: Create context file
```
my-component/
├── index.tsx
├── component-context.tsx
├── component-root.tsx
├── component-item.tsx
└── component-content.tsx
```
Create a context with a null default and a hook that throws on missing provider:
```tsx
// component-context.tsx
const ComponentContext = React.createContext(
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 };
```
### Step 2: Create Root component
Root manages state and provides context. Use `forwardRef`, support `asChild` via Radix `Slot`, and expose state via data attributes:
```tsx
// component-root.tsx
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 (
setIsOpen(!isOpen) }}
>
{children}
);
});
ComponentRoot.displayName = "Component.Root";
```
### Step 3: Create consumer components
Choose the composition pattern based on need:
**Direct children** (simplest, for static content):
```tsx
const Content = ({ children, className, ...props }) => {
const { data } = useComponentContext();
return (
{children}
);
};
```
**Render prop** (when consumer needs internal state):
```tsx
const Content = ({ children, ...props }) => {
const { data, isLoading } = useComponentContext();
const content =
typeof children === "function" ? children({ data, isLoading }) : children;
return {content}
;
};
```
**Sub-context** (for lists where each item needs own context):
```tsx
const Steps = ({ children }) => {
const { reasoning } = useMessageContext();
return (
{children}
);
};
const Step = ({ children, index }) => {
const { steps } = useStepsContext();
return (
{children}
);
};
```
### Step 4: Create namespace export
```tsx
// index.tsx
export const Component = {
Root: ComponentRoot,
Trigger: ComponentTrigger,
Content: ComponentContent,
};
// Re-export types only - never hooks
export type { ComponentRootProps } from "./component-root";
export type { ComponentContentProps } from "./component-content";
```
### Step 5: Verify guidelines
- **No styles in primitives** - consumers control all styling via className/props
- **Data attributes for CSS** - expose state like `data-state="open"`, `data-disabled`, `data-loading`
- **Support asChild** - let consumers swap the underlying element via Radix `Slot`
- **Forward refs** - always use `forwardRef`
- **Display names** - set for DevTools (`Component.Root`, `Component.Item`)
- **Throw on missing context** - fail fast with clear error messages
- **Export types** - consumers need `ComponentProps`, `RenderProps` interfaces
- **Hooks stay internal** - never export from index, expose state via render props
- **SDK hooks OK, custom fetching not** - `@tambo-ai/react` hooks are fine, combining logic goes in styled layer
- **Pre-compute collection props** - use `useMemo` arrays, not getter functions
## Pattern Selection
| 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 |
## 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
- **Exporting hooks** - hooks are internal; expose state via render props
- **Custom data fetching in primitives** - SDK hooks are fine, but combining/external fetching belongs in styled layer
- **Re-implementing base logic** - styled wrappers should compose, not duplicate
- **Getter functions for collections** - pre-compute props arrays in useMemo instead