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".
Create unstyled, composable React components following the Radix UI / Base UI pattern. Components expose behavior via context while consumers control rendering.
These rules are specific to this codebase and override general patterns.
Hooks are implementation details, not public API. Never export hooks from the index.
// 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:
import { useComponentContext } from "../base/component/component-context";
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.
// OK - SDK hooks in primitive
const Root = ({ children }) => {
const { value, setValue, submit } = useTamboThreadInput();
const { isIdle, cancel } = useTamboThread();
return <Context.Provider value={{ value, setValue, isIdle }}>{children}</Context.Provider>;
};
// WRONG - custom data fetching in primitive
const Textarea = ({ resourceProvider }) => {
const { data: mcpResources } = useTamboMcpResourceList(search);
const externalResources = useFetchExternal(resourceProvider);
const combined = [...mcpResources, ...externalResources];
return <div>{combined.map(...)}</div>;
};
When exposing collections via render props, pre-compute all props in a memoized array rather than providing a getter function.
// 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<ItemRenderProps[]>(
() =>
rawItems.map((item, index) => ({
item,
index,
isSelected: selectedId === item.id,
onSelect: () => setSelectedId(item.id),
onRemove: () => removeItem(item.id),
})),
[rawItems, selectedId, removeItem],
);
return children({ items });
};
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
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:
// component-context.tsx
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 manages state and provides context. Use forwardRef, support asChild via Radix Slot, and expose state via data attributes:
// 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 (
<ComponentContext.Provider
value={{ isOpen, toggle: () => setIsOpen(!isOpen) }}
>
<Comp ref={ref} data-state={isOpen ? "open" : "closed"} {...props}>
{children}
</Comp>
</ComponentContext.Provider>
);
});
ComponentRoot.displayName = "Component.Root";
Choose the composition pattern based on need:
Direct children (simplest, for static content):
const Content = ({ children, className, ...props }) => {
const { data } = useComponentContext();
return (
<div className={className} {...props}>
{children}
</div>
);
};
Render prop (when consumer needs internal state):
const Content = ({ children, ...props }) => {
const { data, isLoading } = useComponentContext();
const content =
typeof children === "function" ? children({ data, isLoading }) : children;
return <div {...props}>{content}</div>;
};
Sub-context (for lists where each item needs own context):
const Steps = ({ children }) => {
const { reasoning } = useMessageContext();
return (
<StepsContext.Provider value={{ steps: reasoning }}>
{children}
</StepsContext.Provider>
);
};
const Step = ({ children, index }) => {
const { steps } = useStepsContext();
return (
<StepContext.Provider value={{ step: steps[index], index }}>
{children}
</StepContext.Provider>
);
};
// 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";
data-state="open", data-disabled, data-loadingSlotforwardRefComponent.Root, Component.Item)ComponentProps, RenderProps interfaces@tambo-ai/react hooks are fine, combining logic goes in styled layeruseMemo arrays, not getter functions| 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 |