I used to build components like this:
<Select
options={options}
value={value}
onChange={onChange}
renderOption={(option) => <div>{option.label}</div>}
renderTrigger={(selected) => <button>{selected?.label}</button>}
placeholder="Select an option"
disabled={false}
error={error}
// ... 20 more props
/>
Every new feature meant another prop. Every customization request meant more render functions. The component became a configuration nightmare with 30+ props, and it still couldn't handle all the use cases.
Then I discovered how Radix UI and Headless UI build their components. They use a pattern called compound components, and it changed how I think about component APIs.
<Select value={value} onChange={onChange}>
<Select.Trigger>
<Select.Value placeholder="Select an option" />
<Select.Icon />
</Select.Trigger>
<Select.Content>
{options.map(option => (
<Select.Item key={option.id} value={option.id}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select>
This isn't just prettier syntax. It's a fundamentally different approach to component design that scales infinitely while maintaining perfect type safety.
Let me show you how to build it.
What Are Compound Components?
Compound components are a set of components that work together to form a complete UI element. They share implicit state without prop drilling, and they compose naturally.
Think of HTML elements:
<select>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
The <option> elements don't receive a selected prop. They implicitly know about the <select> parent's state. Compound components bring this pattern to React with full type safety.
Pattern 1: Basic Compound Components with Context
Let's build an Accordion component from scratch.
Step 1: Define the Types
// Accordion.types.ts
import { ReactNode } from 'react';
export interface AccordionContextValue {
openItems: Set<string>;
toggleItem: (id: string) => void;
isOpen: (id: string) => boolean;
}
export interface AccordionProps {
children: ReactNode;
defaultOpen?: string[];
multiple?: boolean;
onChange?: (openItems: string[]) => void;
}
export interface AccordionItemProps {
children: ReactNode;
id: string;
}
export interface AccordionTriggerProps {
children: ReactNode;
}
export interface AccordionContentProps {
children: ReactNode;
}
Step 2: Create the Context
// Accordion.context.ts
import { createContext, useContext } from 'react';
import { AccordionContextValue } from './Accordion.types';
const AccordionContext = createContext<AccordionContextValue | null>(null);
export function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(
'Accordion compound components must be used within <Accordion>'
);
}
return context;
}
export const AccordionProvider = AccordionContext.Provider;
Step 3: Build the Root Component
// Accordion.tsx
import { useState, useCallback } from 'react';
import { AccordionProvider } from './Accordion.context';
import { AccordionProps } from './Accordion.types';
export function Accordion({
children,
defaultOpen = [],
multiple = false,
onChange
}: AccordionProps) {
const [openItems, setOpenItems] = useState<Set<string>>(
new Set(defaultOpen)
);
const toggleItem = useCallback((id: string) => {
setOpenItems(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) {
next.clear();
}
next.add(id);
}
onChange?.(Array.from(next));
return next;
});
}, [multiple, onChange]);
const isOpen = useCallback((id: string) => {
return openItems.has(id);
}, [openItems]);
const contextValue = {
openItems,
toggleItem,
isOpen,
};
return (
<AccordionProvider value={contextValue}>
<div className="accordion">
{children}
</div>
</AccordionProvider>
);
}
Step 4: Build Child Components
// AccordionItem.tsx
import { createContext, useContext, ReactNode } from 'react';
interface AccordionItemContextValue {
id: string;
isOpen: boolean;
}
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
export function useAccordionItemContext() {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error(
'AccordionItem.* components must be used within <Accordion.Item>'
);
}
return context;
}
interface AccordionItemProps {
children: ReactNode;
id: string;
}
export function AccordionItem({ children, id }: AccordionItemProps) {
const { isOpen } = useAccordionContext();
const itemIsOpen = isOpen(id);
const contextValue = {
id,
isOpen: itemIsOpen,
};
return (
<AccordionItemContext.Provider value={contextValue}>
<div className="accordion-item" data-state={itemIsOpen ? 'open' : 'closed'}>
{children}
</div>
</AccordionItemContext.Provider>
);
}
// AccordionTrigger.tsx
export function AccordionTrigger({ children }: { children: ReactNode }) {
const { toggleItem } = useAccordionContext();
const { id, isOpen } = useAccordionItemContext();
return (
<button
className="accordion-trigger"
onClick={() => toggleItem(id)}
aria-expanded={isOpen}
>
{children}
</button>
);
}
// AccordionContent.tsx
export function AccordionContent({ children }: { children: ReactNode }) {
const { isOpen } = useAccordionItemContext();
if (!isOpen) return null;
return (
<div className="accordion-content">
{children}
</div>
);
}
Step 5: Export with Dot Notation
// index.ts
import { Accordion as AccordionRoot } from './Accordion';
import { AccordionItem } from './AccordionItem';
import { AccordionTrigger } from './AccordionTrigger';
import { AccordionContent } from './AccordionContent';
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
// Usage - beautiful, composable API
function MyAccordion() {
return (
<Accordion defaultOpen={['item-1']}>
<Accordion.Item id="item-1">
<Accordion.Trigger>What is TypeScript?</Accordion.Trigger>
<Accordion.Content>
TypeScript is a typed superset of JavaScript.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="item-2">
<Accordion.Trigger>Why use compound components?</Accordion.Trigger>
<Accordion.Content>
They provide flexible, composable APIs with implicit state sharing.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
Pattern 2: Type-Safe Dot Notation
Make TypeScript understand your compound component API.
// Proper typing for dot notation exports
type AccordionComponent = typeof AccordionRoot & {
Item: typeof AccordionItem;
Trigger: typeof AccordionTrigger;
Content: typeof AccordionContent;
};
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
}) as AccordionComponent;
// Now TypeScript knows:
// - Accordion is a component
// - Accordion.Item is a component
// - Accordion.Trigger is a component
// - etc.
Alternative: Namespace Approach
// Accordion.tsx
export function Accordion({ children }: AccordionProps) {
// ... implementation
}
export namespace Accordion {
export const Item = AccordionItem;
export const Trigger = AccordionTrigger;
export const Content = AccordionContent;
}
// Usage is identical
<Accordion>
<Accordion.Item id="1">
<Accordion.Trigger>Question</Accordion.Trigger>
<Accordion.Content>Answer</Accordion.Content>
</Accordion.Item>
</Accordion>
Pattern 3: Polymorphic Compound Components
Allow components to render as different elements.
// types/polymorphic.ts
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
export type AsProp<C extends ElementType> = {
as?: C;
};
export type PropsToOmit<C extends ElementType, P> = keyof (AsProp<C> & P);
export type PolymorphicComponentProp<
C extends ElementType,
Props = {}
> = Props &
AsProp<C> &
Omit<ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
export type PolymorphicRef<C extends ElementType> =
ComponentPropsWithoutRef<C>['ref'];
Building a Polymorphic Trigger
// AccordionTrigger.tsx
import { forwardRef, ElementType } from 'react';
import {
PolymorphicComponentProp,
PolymorphicRef,
} from './types/polymorphic';
type AccordionTriggerProps<C extends ElementType = 'button'> =
PolymorphicComponentProp<
C,
{
children: ReactNode;
}
>;
export const AccordionTrigger = forwardRef(
<C extends ElementType = 'button'>(
{ as, children, ...props }: AccordionTriggerProps<C>,
ref?: PolymorphicRef<C>
) => {
const Component = as || 'button';
const { toggleItem } = useAccordionContext();
const { id, isOpen } = useAccordionItemContext();
return (
<Component
{...props}
ref={ref}
onClick={() => toggleItem(id)}
aria-expanded={isOpen}
>
{children}
</Component>
);
}
);
// Usage - fully typed!
<Accordion.Trigger>Default button</Accordion.Trigger>
<Accordion.Trigger as="div" role="button">Custom div</Accordion.Trigger>
<Accordion.Trigger as="a" href="#section">Link trigger</Accordion.Trigger>
// TypeScript knows which props are available:
<Accordion.Trigger
as="button"
type="button" // ✅ Valid - button has type prop
disabled // ✅ Valid - button has disabled prop
/>
<Accordion.Trigger
as="div"
type="button" // ❌ Error - div doesn't have type prop
tabIndex={0} // ✅ Valid - div has tabIndex
/>
Pattern 4: Generic Compound Components
Build components that work with any data type.
// Select component that works with any data
interface SelectProps<T> {
value: T | null;
onChange: (value: T) => void;
children: ReactNode;
}
interface SelectContextValue<T> {
value: T | null;
onChange: (value: T) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const SelectContext = createContext<SelectContextValue<any> | null>(null);
function useSelectContext<T>() {
const context = useContext(SelectContext) as SelectContextValue<T> | null;
if (!context) {
throw new Error('Select components must be used within <Select>');
}
return context;
}
export function Select<T>({ value, onChange, children }: SelectProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const contextValue: SelectContextValue<T> = {
value,
onChange,
isOpen,
setIsOpen,
};
return (
<SelectContext.Provider value={contextValue}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
// Select.Item is generic too
interface SelectItemProps<T> {
value: T;
children: ReactNode;
}
function SelectItem<T>({ value, children }: SelectItemProps<T>) {
const { value: selectedValue, onChange, setIsOpen } = useSelectContext<T>();
const isSelected = selectedValue === value;
return (
<button
className="select-item"
data-selected={isSelected}
onClick={() => {
onChange(value);
setIsOpen(false);
}}
>
{children}
</button>
);
}
Select.Item = SelectItem;
// Usage - full type inference!
interface User {
id: string;
name: string;
}
const users: User[] = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];
<Select value={selectedUser} onChange={setSelectedUser}>
<Select.Trigger>
{selectedUser?.name ?? 'Select user'}
</Select.Trigger>
<Select.Content>
{users.map(user => (
<Select.Item key={user.id} value={user}>
{/* TypeScript knows user is User */}
{user.name}
</Select.Item>
))}
</Select.Content>
</Select>
Pattern 5: Enforcing Component Structure
Prevent invalid component nesting.
// Tabs component that validates structure
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
tabs: Set<string>;
registerTab: (id: string) => void;
unregisterTab: (id: string) => void;
}
export function Tabs({ children, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
const [tabs, setTabs] = useState<Set<string>>(new Set());
const registerTab = useCallback((id: string) => {
setTabs(prev => new Set(prev).add(id));
}, []);
const unregisterTab = useCallback((id: string) => {
setTabs(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
const contextValue = {
activeTab,
setActiveTab,
tabs,
registerTab,
unregisterTab,
};
return (
<TabsContext.Provider value={contextValue}>
{children}
</TabsContext.Provider>
);
}
// TabPanel auto-registers
export function TabPanel({ id, children }: TabPanelProps) {
const { registerTab, unregisterTab, activeTab } = useTabsContext();
useEffect(() => {
registerTab(id);
return () => unregisterTab(id);
}, [id, registerTab, unregisterTab]);
if (activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
}
// TabTrigger validates tab exists
export function TabTrigger({ id, children }: TabTriggerProps) {
const { setActiveTab, activeTab, tabs } = useTabsContext();
// Warning if tab doesn't exist
useEffect(() => {
if (!tabs.has(id)) {
console.warn(`Tab "${id}" doesn't have a corresponding TabPanel`);
}
}, [id, tabs]);
return (
<button
className="tab-trigger"
data-active={activeTab === id}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
Pattern 6: Render Props with Compound Components
Combine patterns for maximum flexibility.
// Accordion with render props
interface AccordionItemRenderProps {
isOpen: boolean;
toggle: () => void;
}
interface AccordionItemProps {
id: string;
children: (props: AccordionItemRenderProps) => ReactNode;
}
export function AccordionItem({ id, children }: AccordionItemProps) {
const { isOpen, toggleItem } = useAccordionContext();
const itemIsOpen = isOpen(id);
return (
<div className="accordion-item">
{children({
isOpen: itemIsOpen,
toggle: () => toggleItem(id),
})}
</div>
);
}
// Usage - full control over rendering
<Accordion>
<Accordion.Item id="item-1">
{({ isOpen, toggle }) => (
<>
<button onClick={toggle}>
Question {isOpen ? '▼' : '▶'}
</button>
{isOpen && <div>Answer</div>}
</>
)}
</Accordion.Item>
</Accordion>
Pattern 7: Controlled vs Uncontrolled
Support both patterns with TypeScript.
// Union type for controlled vs uncontrolled
type ControlledProps = {
value: string;
onChange: (value: string) => void;
defaultValue?: never;
};
type UncontrolledProps = {
value?: never;
onChange?: never;
defaultValue: string;
};
type SelectProps = (ControlledProps | UncontrolledProps) & {
children: ReactNode;
};
export function Select(props: SelectProps) {
const isControlled = props.value !== undefined;
const [internalValue, setInternalValue] = useState(
props.defaultValue || ''
);
const value = isControlled ? props.value : internalValue;
const onChange = isControlled
? props.onChange
: setInternalValue;
// Rest of implementation uses value and onChange
// ...
}
// TypeScript enforces correct usage:
// ✅ Controlled
<Select value={value} onChange={setValue}>...</Select>
// ✅ Uncontrolled
<Select defaultValue="option1">...</Select>
// ❌ Mixed (TypeScript error)
<Select value={value} defaultValue="option1">...</Select>
// ❌ Missing onChange when controlled
<Select value={value}>...</Select>
Pattern 8: Forwarding Refs Through Compound Components
// AccordionTrigger with ref forwarding
export const AccordionTrigger = forwardRef<
HTMLButtonElement,
AccordionTriggerProps
>(({ children, ...props }, ref) => {
const { toggleItem } = useAccordionContext();
const { id, isOpen } = useAccordionItemContext();
return (
<button
{...props}
ref={ref}
onClick={() => toggleItem(id)}
aria-expanded={isOpen}
>
{children}
</button>
);
});
AccordionTrigger.displayName = 'AccordionTrigger';
// Usage with ref
function MyComponent() {
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
triggerRef.current?.focus();
}, []);
return (
<Accordion>
<Accordion.Item id="1">
<Accordion.Trigger ref={triggerRef}>
Question
</Accordion.Trigger>
<Accordion.Content>Answer</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
Pattern 9: Slot-Based Composition
Alternative pattern using named slots.
// Card with slots
interface CardProps {
children: ReactNode;
}
interface CardSlotContextValue {
slots: {
header?: ReactNode;
content?: ReactNode;
footer?: ReactNode;
};
registerSlot: (name: string, content: ReactNode) => void;
}
const CardSlotContext = createContext<CardSlotContextValue | null>(null);
export function Card({ children }: CardProps) {
const [slots, setSlots] = useState<CardSlotContextValue['slots']>({});
const registerSlot = useCallback((name: string, content: ReactNode) => {
setSlots(prev => ({ ...prev, [name]: content }));
}, []);
return (
<CardSlotContext.Provider value={{ slots, registerSlot }}>
{/* Process children to register slots */}
<div style={{ display: 'none' }}>{children}</div>
{/* Render slots in specific order */}
<div className="card">
{slots.header && <div className="card-header">{slots.header}</div>}
{slots.content && <div className="card-content">{slots.content}</div>}
{slots.footer && <div className="card-footer">{slots.footer}</div>}
</div>
</CardSlotContext.Provider>
);
}
function CardHeader({ children }: { children: ReactNode }) {
const { registerSlot } = useContext(CardSlotContext)!;
useEffect(() => {
registerSlot('header', children);
}, [children, registerSlot]);
return null;
}
function CardContent({ children }: { children: ReactNode }) {
const { registerSlot } = useContext(CardSlotContext)!;
useEffect(() => {
registerSlot('content', children);
}, [children, registerSlot]);
return null;
}
Card.Header = CardHeader;
Card.Content = CardContent;
// Usage - order doesn't matter!
<Card>
<Card.Content>This renders second</Card.Content>
<Card.Header>This renders first</Card.Header>
</Card>
Pattern 10: Complex State Management
Use useReducer for complex compound component state.
// Dropdown with complex state
type DropdownState = {
isOpen: boolean;
selectedIndex: number;
searchQuery: string;
highlightedIndex: number;
};
type DropdownAction =
| { type: 'OPEN' }
| { type: 'CLOSE' }
| { type: 'TOGGLE' }
| { type: 'SELECT'; index: number }
| { type: 'HIGHLIGHT'; index: number }
| { type: 'SEARCH'; query: string }
| { type: 'KEYBOARD_NAVIGATE'; direction: 'up' | 'down' }
| { type: 'RESET' };
function dropdownReducer(
state: DropdownState,
action: DropdownAction
): DropdownState {
switch (action.type) {
case 'OPEN':
return { ...state, isOpen: true };
case 'CLOSE':
return { ...state, isOpen: false, searchQuery: '' };
case 'TOGGLE':
return { ...state, isOpen: !state.isOpen };
case 'SELECT':
return {
...state,
selectedIndex: action.index,
isOpen: false,
searchQuery: '',
};
case 'HIGHLIGHT':
return { ...state, highlightedIndex: action.index };
case 'SEARCH':
return { ...state, searchQuery: action.query };
case 'KEYBOARD_NAVIGATE':
const direction = action.direction === 'up' ? -1 : 1;
return {
...state,
highlightedIndex: Math.max(
0,
Math.min(state.highlightedIndex + direction, /* itemCount - 1 */ 10)
),
};
case 'RESET':
return {
isOpen: false,
selectedIndex: -1,
searchQuery: '',
highlightedIndex: -1,
};
default:
return state;
}
}
export function Dropdown({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(dropdownReducer, {
isOpen: false,
selectedIndex: -1,
searchQuery: '',
highlightedIndex: -1,
});
const contextValue = {
state,
dispatch,
};
return (
<DropdownContext.Provider value={contextValue}>
{children}
</DropdownContext.Provider>
);
}
Real-World Example: Complete Select Component
// Select/index.tsx - Full implementation
import { useState, useRef, useEffect, ReactNode, forwardRef } from 'react';
// Types
interface SelectContextValue<T> {
value: T | null;
onChange: (value: T) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement>;
}
// Context
const SelectContext = createContext<SelectContextValue<any> | null>(null);
function useSelectContext<T>() {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select.* components must be used within <Select>');
}
return context as SelectContextValue<T>;
}
// Root Component
interface SelectProps<T> {
value: T | null;
onChange: (value: T) => void;
children: ReactNode;
}
function SelectRoot<T>({ value, onChange, children }: SelectProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
if (!triggerRef.current?.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [isOpen]);
const contextValue: SelectContextValue<T> = {
value,
onChange,
isOpen,
setIsOpen,
triggerRef,
};
return (
<SelectContext.Provider value={contextValue}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
// Trigger Component
interface SelectTriggerProps {
children: ReactNode;
}
const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ children }, ref) => {
const { isOpen, setIsOpen, triggerRef } = useSelectContext();
return (
<button
ref={(node) => {
(triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
className="select-trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
{children}
</button>
);
}
);
// Value Component
function SelectValue({ placeholder }: { placeholder?: string }) {
const { value } = useSelectContext();
if (value === null) {
return <span className="select-placeholder">{placeholder}</span>;
}
// User provides the display via children in SelectItem
return null;
}
// Content Component
function SelectContent({ children }: { children: ReactNode }) {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return (
<div className="select-content">
{children}
</div>
);
}
// Item Component
interface SelectItemProps<T> {
value: T;
children: ReactNode;
}
function SelectItem<T>({ value, children }: SelectItemProps<T>) {
const { value: selectedValue, onChange, setIsOpen } = useSelectContext<T>();
const isSelected = selectedValue === value;
return (
<button
className="select-item"
data-selected={isSelected}
onClick={() => {
onChange(value);
setIsOpen(false);
}}
>
{children}
{isSelected && <span className="select-checkmark">✓</span>}
</button>
);
}
// Export with dot notation
export const Select = Object.assign(SelectRoot, {
Trigger: SelectTrigger,
Value: SelectValue,
Content: SelectContent,
Item: SelectItem,
});
// Usage
interface Option {
id: string;
label: string;
}
function App() {
const [selected, setSelected] = useState<Option | null>(null);
const options: Option[] = [
{ id: '1', label: 'Option 1' },
{ id: '2', label: 'Option 2' },
{ id: '3', label: 'Option 3' },
];
return (
<Select value={selected} onChange={setSelected}>
<Select.Trigger>
<Select.Value placeholder="Select an option" />
{selected && <span>{selected.label}</span>}
</Select.Trigger>
<Select.Content>
{options.map(option => (
<Select.Item key={option.id} value={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select>
);
}
Advantages Over Alternative Patterns
vs. Monolithic Components
// ❌ Monolithic - inflexible
<Select
options={options}
renderOption={(opt) => <div>{opt.label}</div>}
renderTrigger={(val) => <button>{val?.label}</button>}
icon={<ChevronIcon />}
searchable={true}
clearable={true}
// 20+ more props
/>
// ✅ Compound - composable
<Select value={value} onChange={onChange}>
<Select.Trigger>
{value?.label ?? 'Select'}
<ChevronIcon />
</Select.Trigger>
<Select.Content>
<Select.Search />
{options.map(opt => (
<Select.Item value={opt}>{opt.label}</Select.Item>
))}
</Select.Content>
</Select>
vs. Render Props
// ❌ Render props - nesting hell
<Select value={value} onChange={onChange}>
{({ isOpen, selectedValue }) => (
<>
<SelectTrigger>
{({ toggle }) => (
<button onClick={toggle}>
{selectedValue?.label ?? 'Select'}
</button>
)}
</SelectTrigger>
{isOpen && (
<SelectContent>
{({ selectOption }) => (
options.map(opt => (
<button onClick={() => selectOption(opt)}>
{opt.label}
</button>
))
)}
</SelectContent>
)}
</>
)}
</Select>
// ✅ Compound - flat and readable
<Select value={value} onChange={onChange}>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Content>
{options.map(opt => (
<Select.Item value={opt}>{opt.label}</Select.Item>
))}
</Select.Content>
</Select>
Best Practices
1. Clear Component Names
// ✅ Good - obvious hierarchy
<Accordion>
<Accordion.Item>
<Accordion.Trigger />
<Accordion.Content />
</Accordion.Item>
</Accordion>
// ❌ Bad - unclear relationship
<Accordion>
<Item>
<Button />
<Panel />
</Item>
</Accordion>
2. Throw Helpful Errors
function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(
'Accordion.* components must be used within <Accordion>. ' +
'Did you forget to wrap your components?'
);
}
return context;
}
3. Document Component Structure
/**
* Accordion component for collapsible content sections.
*
* @example
* ```
tsx
* <Accordion defaultOpen={['item-1']}>
* <Accordion.Item id="item-1">
* <Accordion.Trigger>Question</Accordion.Trigger>
* <Accordion.Content>Answer</Accordion.Content>
* </Accordion.Item>
* </Accordion>
*
/
export const Accordion = { / ... */ };
### 4. Support Composition
```typescript
// Allow custom components between layers
<Accordion>
<div className="accordion-section">
<h2>Section Title</h2>
<Accordion.Item id="1">
<Accordion.Trigger>Question</Accordion.Trigger>
<Accordion.Content>Answer</Accordion.Content>
</Accordion.Item>
</div>
</Accordion>
When to Use Compound Components
Use compound components when:
- Components have complex internal state
- Multiple child components need to share state
- You want maximum customization flexibility
- The component hierarchy is important
- You're building a design system
Don't use compound components when:
- A simple component with 3-5 props would work
- There's no shared state between children
- The API would be confusing for your use case
- You're building a one-off component
Conclusion
Compound components transform component APIs from configuration nightmares into composable, flexible systems.
Instead of:
- 30+ props
- Complex render prop functions
- Limited customization
- Prop drilling
You get:
- Implicit state sharing
- Infinite customization
- Clear component hierarchy
- Perfect TypeScript support
The pattern takes more code upfront, but it scales infinitely. Every design system library uses it for a reason.
Start with a simple compound component. Build an Accordion or Tabs. Once you see how the pattern works, you'll wonder how you ever lived without it.
What components would benefit from the compound pattern in your codebase? Share your ideas!
Top comments (0)