The Compound Pattern in React represents a powerful design approach that enables developers to create flexible, reusable components that work together seamlessly. This pattern addresses common challenges like prop drilling while promoting declarative code that's both readable and maintainable. This comprehensive guide explores the compound pattern through practical TypeScript examples, performance considerations, and advanced implementations.
Understanding the Compound Pattern
The Compound Pattern involves creating a parent component that manages state and behavior, while child components consume and display this state through a shared context. Compound components are a pattern in React, where several components are used together such that they share an implicit state that allows them to communicate with each other in the background. This pattern is particularly effective when multiple components need to work together to accomplish a shared task.
What Makes It Special?
Compound components can be said to be a pattern that encloses the state and the behavior of a group of components but still gives the rendering control of its variable parts back to the external user. This approach provides several key advantages:
- Declarative API: Components express what they do, not how they do it
- Flexible Composition: Users can arrange child components in any order
- Implicit State Sharing: Components communicate without explicit prop passing
- Enhanced Reusability: Each component can be used independently when needed
- Type Safety: TypeScript provides excellent support for this pattern
The Prop Drilling Problem
Before diving into the compound pattern, let's examine the traditional prop drilling approach and its limitations.
Traditional Prop Drilling Example
Consider a simple menu component built with traditional prop drilling:
// Traditional approach with prop drilling
interface MenuItem {
id: string;
label: string;
}
interface MenuProps {
isOpen: boolean;
onToggle: () => void;
items: MenuItem[];
activeItem: string | null;
onItemClick: (id: string) => void;
}
function Menu({ isOpen, onToggle, items, activeItem, onItemClick }: MenuProps) {
return (
<div className="menu">
<MenuButton isOpen={isOpen} onToggle={onToggle} />
<MenuList
isOpen={isOpen}
items={items}
activeItem={activeItem}
onItemClick={onItemClick}
/>
</div>
);
}
interface MenuButtonProps {
isOpen: boolean;
onToggle: () => void;
}
function MenuButton({ isOpen, onToggle }: MenuButtonProps) {
return (
<button onClick={onToggle}>
{isOpen ? 'Close' : 'Open'} Menu
</button>
);
}
interface MenuListProps {
isOpen: boolean;
items: MenuItem[];
activeItem: string | null;
onItemClick: (id: string) => void;
}
function MenuList({ isOpen, items, activeItem, onItemClick }: MenuListProps) {
if (!isOpen) return null;
return (
<ul>
{items.map(item => (
<MenuItemComponent
key={item.id}
item={item}
isActive={item.id === activeItem}
onItemClick={onItemClick}
/>
))}
</ul>
);
}
interface MenuItemComponentProps {
item: MenuItem;
isActive: boolean;
onItemClick: (id: string) => void;
}
function MenuItemComponent({ item, isActive, onItemClick }: MenuItemComponentProps) {
return (
<li
className={isActive ? 'active' : ''}
onClick={() => onItemClick(item.id)}
>
{item.label}
</li>
);
}
// Usage
function App() {
const [isOpen, setIsOpen] = useState(false);
const [activeItem, setActiveItem] = useState<string | null>(null);
const items: MenuItem[] = [
{ id: '1', label: 'Home' },
{ id: '2', label: 'About' },
{ id: '3', label: 'Contact' }
];
return (
<Menu
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
items={items}
activeItem={activeItem}
onItemClick={setActiveItem}
/>
);
}
Problems with This Approach
The traditional approach presents several challenges:
- Tight Coupling: Components are tightly bound to their parent's prop structure
- Verbose Props: Every intermediate component must accept and pass props
- Limited Flexibility: Changing the component structure requires updating multiple prop interfaces
- Poor Reusability: Components become difficult to reuse in different contexts
- Complex Type Definitions: Managing types across multiple levels becomes cumbersome
Compound Pattern Solution
The compound pattern elegantly solves these problems by using React Context to share state implicitly among related components.
Basic Compound Pattern Implementation
import React, { createContext, useContext, useState, ReactNode } from 'react';
// Define the context type
interface MenuContextType {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
activeItem: string | null;
setActiveItem: (id: string | null) => void;
toggle: () => void;
selectItem: (id: string) => void;
}
// Create context for the compound component
const MenuContext = createContext<MenuContextType | undefined>(undefined);
// Custom hook to access menu context
function useMenuContext(): MenuContextType {
const context = useContext(MenuContext);
if (!context) {
throw new Error('Menu compound components must be used within Menu');
}
return context;
}
// Main Menu component interface
interface MenuProps {
children: ReactNode;
}
// Main Menu component that provides context
function Menu({ children }: MenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeItem, setActiveItem] = useState<string | null>(null);
const contextValue: MenuContextType = {
isOpen,
setIsOpen,
activeItem,
setActiveItem,
toggle: () => setIsOpen(!isOpen),
selectItem: (id: string) => setActiveItem(id)
};
return (
<MenuContext.Provider value={contextValue}>
<div className="menu">
{children}
</div>
</MenuContext.Provider>
);
}
// Compound components with proper typing
interface MenuButtonProps {
children?: ReactNode;
}
Menu.Button = function MenuButton({ children }: MenuButtonProps) {
const { isOpen, toggle } = useMenuContext();
return (
<button onClick={toggle}>
{children || (isOpen ? 'Close Menu' : 'Open Menu')}
</button>
);
};
interface MenuListProps {
children: ReactNode;
}
Menu.List = function MenuList({ children }: MenuListProps) {
const { isOpen } = useMenuContext();
if (!isOpen) return null;
return <ul className="menu-list">{children}</ul>;
};
interface MenuItemProps {
id: string;
children: ReactNode;
}
Menu.Item = function MenuItem({ id, children }: MenuItemProps) {
const { activeItem, selectItem } = useMenuContext();
const isActive = activeItem === id;
return (
<li
className={`menu-item ${isActive ? 'active' : ''}`}
onClick={() => selectItem(id)}
>
{children}
</li>
);
};
// Clean, declarative usage with full type safety
function App() {
return (
<Menu>
<Menu.Button>Toggle Navigation</Menu.Button>
<Menu.List>
<Menu.Item id="home">Home</Menu.Item>
<Menu.Item id="about">About</Menu.Item>
<Menu.Item id="services">Services</Menu.Item>
<Menu.Item id="contact">Contact</Menu.Item>
</Menu.List>
</Menu>
);
}
Key Improvements
The compound pattern version demonstrates several improvements:
- No Prop Drilling: State is shared through context, eliminating prop passing
- Declarative Syntax: The usage clearly expresses the component structure
- Flexible Composition: Items can be arranged in any order or configuration
- Implicit Communication: Components automatically stay in sync
- Type Safety: Full TypeScript support with proper error handling
Advanced Compound Pattern Implementations
1. Flexible Dialog Component
Here's a more complex example showcasing the pattern's power with a dialog component:
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
MouseEvent,
KeyboardEvent
} from 'react';
interface DialogContextType {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
open: () => void;
close: () => void;
showLoading: () => void;
hideLoading: () => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
function useDialogContext(): DialogContextType {
const context = useContext(DialogContext);
if (!context) {
throw new Error('Dialog compound components must be used within Dialog');
}
return context;
}
interface DialogProps {
children: ReactNode;
onClose?: () => void;
}
function Dialog({ children, onClose }: DialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const contextValue: DialogContextType = {
isOpen,
setIsOpen,
loading,
setLoading,
open: () => setIsOpen(true),
close: () => {
setIsOpen(false);
onClose?.();
},
showLoading: () => setLoading(true),
hideLoading: () => setLoading(false)
};
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent<Document>) => {
if (e.key === 'Escape' && isOpen) {
contextValue.close();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape as any);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape as any);
document.body.style.overflow = 'unset';
};
}, [isOpen, contextValue]);
return (
<DialogContext.Provider value={contextValue}>
{children}
</DialogContext.Provider>
);
}
interface DialogTriggerProps {
children: ReactNode;
asChild?: boolean;
}
Dialog.Trigger = function DialogTrigger({ children, asChild = false }: DialogTriggerProps) {
const { open } = useDialogContext();
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, { onClick: open });
}
return <button onClick={open}>{children}</button>;
};
interface DialogContentProps {
children: ReactNode;
className?: string;
}
Dialog.Content = function DialogContent({ children, className = '' }: DialogContentProps) {
const { isOpen, close } = useDialogContext();
if (!isOpen) return null;
const handleOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
close();
}
};
return (
<div className={`dialog-overlay ${className}`} onClick={handleOverlayClick}>
<div className="dialog-content">
{children}
</div>
</div>
);
};
interface DialogHeaderProps {
children: ReactNode;
}
Dialog.Header = function DialogHeader({ children }: DialogHeaderProps) {
return <div className="dialog-header">{children}</div>;
};
interface DialogBodyProps {
children: ReactNode;
}
Dialog.Body = function DialogBody({ children }: DialogBodyProps) {
const { loading } = useDialogContext();
return (
<div className="dialog-body">
{loading && <div className="loading-spinner">Loading...</div>}
{children}
</div>
);
};
interface DialogFooterProps {
children: ReactNode;
}
Dialog.Footer = function DialogFooter({ children }: DialogFooterProps) {
return <div className="dialog-footer">{children}</div>;
};
interface DialogCloseProps {
children?: ReactNode;
}
Dialog.Close = function DialogClose({ children }: DialogCloseProps) {
const { close } = useDialogContext();
return (
<button className="dialog-close" onClick={close}>
{children || '×'}
</button>
);
};
// Usage example with full type safety
function UserProfile() {
const handleSave = async (): Promise<void> => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
};
return (
<Dialog onClose={() => console.log('Dialog closed')}>
<Dialog.Trigger>
<button className="primary-btn">Edit Profile</button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<h2>Edit Profile</h2>
<Dialog.Close />
</Dialog.Header>
<Dialog.Body>
<form>
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<textarea placeholder="Bio"></textarea>
</form>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close>
<button className="secondary-btn">Cancel</button>
</Dialog.Close>
<button className="primary-btn" onClick={handleSave}>
Save Changes
</button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
2. Advanced Data Table Component
For more complex scenarios, here's a data table implementation that showcases the pattern's scalability:
import React, {
createContext,
useContext,
useState,
useMemo,
ReactNode
} from 'react';
// Generic type for table data
interface TableData {
id: string | number;
[key: string]: any;
}
type SortDirection = 'asc' | 'desc';
interface TableContextType<T extends TableData> {
data: T[];
originalData: T[];
sortColumn: keyof T | null;
sortDirection: SortDirection;
filters: Record<string, string>;
selectedRows: Set<string | number>;
sort: (column: keyof T) => void;
filter: (column: string, value: string) => void;
selectRow: (id: string | number) => void;
selectAllRows: () => void;
clearSelection: () => void;
}
const TableContext = createContext<TableContextType<any> | undefined>(undefined);
function useTableContext<T extends TableData>(): TableContextType<T> {
const context = useContext(TableContext);
if (!context) {
throw new Error('Table compound components must be used within Table');
}
return context;
}
interface TableProps<T extends TableData> {
data: T[];
children: ReactNode;
}
function Table<T extends TableData>({ data, children }: TableProps<T>) {
const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [filters, setFilters] = useState<Record<string, string>>({});
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
const processedData = useMemo(() => {
let result = [...data];
// Apply filters
Object.entries(filters).forEach(([column, value]) => {
if (value) {
result = result.filter(row =>
String(row[column]).toLowerCase().includes(value.toLowerCase())
);
}
});
// Apply sorting
if (sortColumn) {
result.sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
const modifier = sortDirection === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * modifier;
if (aVal > bVal) return 1 * modifier;
return 0;
});
}
return result;
}, [data, filters, sortColumn, sortDirection]);
const contextValue: TableContextType<T> = {
data: processedData,
originalData: data,
sortColumn,
sortDirection,
filters,
selectedRows,
sort: (column: keyof T) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
},
filter: (column: string, value: string) => {
setFilters(prev => ({ ...prev, [column]: value }));
},
selectRow: (id: string | number) => {
setSelectedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
},
selectAllRows: () => {
setSelectedRows(new Set(processedData.map(row => row.id)));
},
clearSelection: () => {
setSelectedRows(new Set());
}
};
return (
<TableContext.Provider value={contextValue}>
<div className="table-container">
{children}
</div>
</TableContext.Provider>
);
}
interface TableHeaderProps {
children: ReactNode;
}
Table.Header = function TableHeader({ children }: TableHeaderProps) {
return <thead className="table-header">{children}</thead>;
};
interface TableBodyProps {
children: ReactNode;
}
Table.Body = function TableBody({ children }: TableBodyProps) {
const { data } = useTableContext();
return (
<tbody className="table-body">
{data.map((row, index) => (
<TableRowProvider key={row.id} row={row} index={index}>
{children}
</TableRowProvider>
))}
</tbody>
);
};
interface TableRowProps {
children: ReactNode;
}
Table.Row = function TableRow({ children }: TableRowProps) {
const { row, index } = useTableRowContext();
const { selectedRows } = useTableContext();
return (
<tr
className={`table-row ${selectedRows.has(row.id) ? 'selected' : ''}`}
data-index={index}
>
{children}
</tr>
);
};
interface TableHeaderCellProps {
column?: string;
children: ReactNode;
sortable?: boolean;
}
Table.HeaderCell = function TableHeaderCell({
column,
children,
sortable = false
}: TableHeaderCellProps) {
const { sort, sortColumn, sortDirection } = useTableContext();
const handleClick = () => {
if (sortable && column) {
sort(column);
}
};
return (
<th
className={`table-header-cell ${sortable ? 'sortable' : ''}`}
onClick={handleClick}
>
{children}
{sortable && sortColumn === column && (
<span className="sort-indicator">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
);
};
interface TableCellProps {
column?: string;
children?: ReactNode;
}
Table.Cell = function TableCell({ column, children }: TableCellProps) {
const { row } = useTableRowContext();
return (
<td className="table-cell">
{children || (column ? row[column] : '')}
</td>
);
};
Table.SelectCell = function TableSelectCell() {
const { row } = useTableRowContext();
const { selectedRows, selectRow } = useTableContext();
return (
<td className="table-select-cell">
<input
type="checkbox"
checked={selectedRows.has(row.id)}
onChange={() => selectRow(row.id)}
/>
</td>
);
};
interface TableFilterProps {
column: string;
placeholder?: string;
}
Table.Filter = function TableFilter({ column, placeholder }: TableFilterProps) {
const { filters, filter } = useTableContext();
return (
<input
type="text"
placeholder={placeholder}
value={filters[column] || ''}
onChange={(e) => filter(column, e.target.value)}
className="table-filter"
/>
);
};
// Row context for accessing current row data
interface TableRowContextType<T extends TableData = TableData> {
row: T;
index: number;
}
const TableRowContext = createContext<TableRowContextType | undefined>(undefined);
interface TableRowProviderProps<T extends TableData> {
row: T;
index: number;
children: ReactNode;
}
function TableRowProvider<T extends TableData>({
row,
index,
children
}: TableRowProviderProps<T>) {
return (
<TableRowContext.Provider value={{ row, index }}>
{children}
</TableRowContext.Provider>
);
}
function useTableRowContext<T extends TableData = TableData>(): TableRowContextType<T> {
const context = useContext(TableRowContext);
if (!context) {
throw new Error('Table row components must be used within Table.Body');
}
return context as TableRowContextType<T>;
}
// Usage example with proper typing
interface User {
id: string;
name: string;
email: string;
role: 'Admin' | 'User';
}
function UsersTable() {
const users: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com', role: 'Admin' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'User' },
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'User' }
];
return (
<div>
<div className="table-controls">
<Table.Filter column="name" placeholder="Filter by name..." />
<Table.Filter column="email" placeholder="Filter by email..." />
</div>
<table>
<Table<User> data={users}>
<Table.Header>
<tr>
<Table.HeaderCell>
<input type="checkbox" />
</Table.HeaderCell>
<Table.HeaderCell column="name" sortable>
Name
</Table.HeaderCell>
<Table.HeaderCell column="email" sortable>
Email
</Table.HeaderCell>
<Table.HeaderCell column="role">
Role
</Table.HeaderCell>
</tr>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.SelectCell />
<Table.Cell column="name" />
<Table.Cell column="email" />
<Table.Cell column="role">
<span className="role-badge">
{/* Custom rendering with full type safety */}
</span>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</div>
</div>
);
}
Performance Considerations
While the compound pattern offers excellent developer experience, it's important to consider performance implications:
Context Performance Optimization
import React, {
createContext,
useContext,
useMemo,
useCallback,
useState,
ReactNode
} from 'react';
interface MenuState {
isOpen: boolean;
activeItem: string | null;
}
interface MenuActions {
toggle: () => void;
selectItem: (id: string) => void;
}
const MenuStateContext = createContext<MenuState | undefined>(undefined);
const MenuActionsContext = createContext<MenuActions | undefined>(undefined);
interface OptimizedMenuProps {
children: ReactNode;
}
function OptimizedMenu({ children }: OptimizedMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeItem, setActiveItem] = useState<string | null>(null);
// Separate state context (changes frequently)
const stateContextValue = useMemo((): MenuState => ({
isOpen,
activeItem
}), [isOpen, activeItem]);
// Separate actions context (stable)
const actionsContextValue = useMemo((): MenuActions => ({
toggle: () => setIsOpen(prev => !prev),
selectItem: (id: string) => setActiveItem(id)
}), []);
return (
<MenuStateContext.Provider value={stateContextValue}>
<MenuActionsContext.Provider value={actionsContextValue}>
<div className="menu">{children}</div>
</MenuActionsContext.Provider>
</MenuStateContext.Provider>
);
}
// Separate hooks for better performance
function useMenuState(): MenuState {
const context = useContext(MenuStateContext);
if (!context) {
throw new Error('useMenuState must be used within OptimizedMenu');
}
return context;
}
function useMenuActions(): MenuActions {
const context = useContext(MenuActionsContext);
if (!context) {
throw new Error('useMenuActions must be used within OptimizedMenu');
}
return context;
}
Best Practices for Performance
- Split Contexts: Separate frequently changing state from stable actions
- Memoize Context Values: Use
useMemo
to prevent unnecessary re-renders - Optimize Child Components: Use
React.memo
for compound components when appropriate - Lazy Evaluation: Only compute expensive values when needed
- Proper TypeScript: Use generics and proper typing for better performance and DX
When to Use the Compound Pattern
The compound pattern shines in specific scenarios:
Ideal Use Cases
- Related Component Groups: When components naturally work together (tabs, accordion, dropdown)
- Complex State Management: When multiple components need to share and synchronize state
- Flexible APIs: When users need control over component arrangement and styling
- Design Systems: When building reusable component libraries with TypeScript
- Library Development: When creating components that need to be highly composable
When to Avoid
- Simple Components: When prop drilling involves only one or two levels
- Performance-Critical Paths: When context updates would cause expensive re-renders
- Unrelated Components: When components don't naturally belong together
- Over-Engineering: When simpler patterns would suffice
Comparison with Other Patterns
Compound Pattern vs. Render Props
// Render Props approach
interface MenuRenderProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
toggle: () => void;
}
interface MenuWithRenderProps {
children: (props: MenuRenderProps) => ReactNode;
}
function MenuWithRenderProps({ children }: MenuWithRenderProps) {
const [isOpen, setIsOpen] = useState(false);
return children({
isOpen,
setIsOpen,
toggle: () => setIsOpen(!isOpen)
});
}
// Usage - less declarative
<MenuWithRenderProps>
{({ isOpen, toggle }) => (
<div>
<button onClick={toggle}>Toggle</button>
{isOpen && <ul>...</ul>}
</div>
)}
</MenuWithRenderProps>
// Compound Pattern is more declarative and readable
<Menu>
<Menu.Button>Toggle</Menu.Button>
<Menu.List>...</Menu.List>
</Menu>
Compound Pattern vs. HOCs
The compound pattern offers better composition and avoids the wrapper hell often associated with Higher-Order Components while providing excellent TypeScript support and better inference.
Extending the pattern: beyond composition
Once you've grasped the power of the Compound Pattern, it's time to push beyond. Here are some advanced techniques that can transform your components from simple building blocks into true user interface ecosystems.
Auto-Detection of Child Components
Sometimes exposing a declarative API isn't enough, we also need to be smart about how our components behave based on their structure. Using React.Children
and traversal functions, we can create components that automatically adapt to their content.
import React, { ReactNode, ReactElement, Children, isValidElement } from 'react';
interface TabsProps {
children: ReactNode;
defaultValue?: string;
}
function Tabs({ children, defaultValue }: TabsProps) {
const [activeTab, setActiveTab] = useState<string>(() => {
// Auto-detect first tab if no default provided
if (defaultValue) return defaultValue;
const tabList = Children.toArray(children).find(
(child): child is ReactElement =>
isValidElement(child) && child.type === Tabs.List
);
if (tabList) {
const firstTrigger = Children.toArray(tabList.props.children).find(
(child): child is ReactElement =>
isValidElement(child) && child.type === Tabs.Trigger
);
return firstTrigger?.props.value || '';
}
return '';
});
const contextValue = {
activeTab,
setActiveTab,
// Auto-detect available tabs
availableTabs: useMemo(() => {
const tabs: string[] = [];
Children.forEach(children, (child) => {
if (isValidElement(child) && child.type === Tabs.Content) {
tabs.push(child.props.value);
}
});
return tabs;
}, [children])
};
return (
<TabsContext.Provider value={contextValue}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
Tabs.List = function TabsList({ children }: { children: ReactNode }) {
const { availableTabs } = useTabsContext();
// Auto-filter only valid triggers
const validTriggers = Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
child.type === Tabs.Trigger &&
availableTabs.includes(child.props.value)
);
return (
<div className="tabs-list" role="tablist">
{validTriggers}
</div>
);
};
Tabs.Trigger = function TabsTrigger({
value,
children
}: {
value: string;
children: ReactNode
}) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeTab === value}
className={`tabs-trigger ${activeTab === value ? 'active' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
};
Tabs.Content = function TabsContent({
value,
children
}: {
value: string;
children: ReactNode
}) {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
return (
<div role="tabpanel" className="tabs-content">
{children}
</div>
);
};
// Usage with auto-detection
function SmartTabs() {
return (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
<Tabs.Trigger value="invalid">Invalid</Tabs.Trigger> {/* Automatically filtered */}
</Tabs.List>
<Tabs.Content value="overview">
<h2>Overview Content</h2>
</Tabs.Content>
<Tabs.Content value="settings">
<h2>Settings Content</h2>
</Tabs.Content>
{/* The "invalid" trigger has no corresponding content */}
</Tabs>
);
}
Context Overrides at Intermediate Levels
One of the most powerful patterns is the ability to override compound component behavior at intermediate levels. This allows you to create nested components that can modify parent behavior without breaking the communication chain.
interface NestedMenuContextType extends MenuContextType {
level: number;
parentContext?: MenuContextType;
}
const NestedMenuContext = createContext<NestedMenuContextType | undefined>(undefined);
interface SubmenuProps {
children: ReactNode;
trigger: ReactNode;
disabled?: boolean;
}
function Submenu({ children, trigger, disabled = false }: SubmenuProps) {
const parentContext = useMenuContext();
const [isOpen, setIsOpen] = useState(false);
// Override specific behaviors while preserving parent context
const nestedContextValue: NestedMenuContextType = {
...parentContext,
level: (parentContext as any).level + 1 || 1,
parentContext,
isOpen,
setIsOpen,
toggle: () => !disabled && setIsOpen(!isOpen),
// Preserve parent's active item logic
selectItem: (id: string) => {
parentContext.selectItem(id);
setIsOpen(false); // Close submenu when item is selected
}
};
return (
<NestedMenuContext.Provider value={nestedContextValue}>
<div className={`submenu level-${nestedContextValue.level}`}>
<div
className={`submenu-trigger ${disabled ? 'disabled' : ''}`}
onClick={nestedContextValue.toggle}
>
{trigger}
</div>
{isOpen && (
<div className="submenu-content">
{children}
</div>
)}
</div>
</NestedMenuContext.Provider>
);
}
// Hook that works with both normal and nested context
function useMenuOrNestedContext(): MenuContextType | NestedMenuContextType {
const nestedContext = useContext(NestedMenuContext);
const menuContext = useMenuContext();
return nestedContext || menuContext;
}
// Usage with nested overrides
function AdvancedMenu() {
return (
<Menu>
<Menu.Button>Main Menu</Menu.Button>
<Menu.List>
<Menu.Item id="home">Home</Menu.Item>
<Submenu trigger={<span>Products ▶</span>}>
<Menu.Item id="product-1">Product 1</Menu.Item>
<Menu.Item id="product-2">Product 2</Menu.Item>
<Submenu trigger={<span>Categories ▶</span>}>
<Menu.Item id="cat-1">Category 1</Menu.Item>
<Menu.Item id="cat-2">Category 2</Menu.Item>
</Submenu>
</Submenu>
<Menu.Item id="about">About</Menu.Item>
</Menu.List>
</Menu>
);
}
Dynamic Slots: The ShadCN/Radix Pattern
The true power of the compound pattern emerges when we implement dynamic slots, allowing components to adapt and reorganize based on content. This is the secret behind the flexibility of libraries like Radix UI and ShadCN.
interface SlotProps {
children?: ReactNode;
asChild?: boolean;
}
// Primitive Slot component inspired by Radix
function Slot({ children, asChild = false, ...props }: SlotProps & any) {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
...children.props,
className: `${props.className || ''} ${children.props.className || ''}`.trim()
});
}
return React.createElement('div', props, children);
}
// Advanced Dialog with dynamic slots
interface DialogWithSlotsProps {
children: ReactNode;
modal?: boolean;
onOpenChange?: (open: boolean) => void;
}
function DialogWithSlots({ children, modal = true, onOpenChange }: DialogWithSlotsProps) {
const [isOpen, setIsOpen] = useState(false);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
onOpenChange?.(open);
};
// Detect and organize slots automatically
const slots = useMemo(() => {
const result: {
trigger?: ReactElement;
content?: ReactElement;
overlay?: ReactElement;
portal?: ReactElement;
} = {};
Children.forEach(children, (child) => {
if (isValidElement(child)) {
const displayName = (child.type as any).displayName;
switch (displayName) {
case 'DialogTrigger':
result.trigger = child;
break;
case 'DialogContent':
result.content = child;
break;
case 'DialogOverlay':
result.overlay = child;
break;
case 'DialogPortal':
result.portal = child;
break;
}
}
});
return result;
}, [children]);
const contextValue = {
isOpen,
onOpenChange: handleOpenChange,
modal,
slots
};
return (
<DialogContext.Provider value={contextValue}>
{slots.trigger}
{isOpen && (
modal ? (
<DialogPortal>
{slots.overlay}
{slots.content}
</DialogPortal>
) : (
<>
{slots.overlay}
{slots.content}
</>
)
)}
</DialogContext.Provider>
);
}
// Dynamic slot components
interface DialogTriggerProps {
children: ReactNode;
asChild?: boolean;
}
DialogWithSlots.Trigger = function DialogTrigger({
children,
asChild = false
}: DialogTriggerProps) {
const { onOpenChange } = useDialogContext();
const handleClick = () => onOpenChange(true);
return (
<Slot asChild={asChild} onClick={handleClick}>
{children}
</Slot>
);
};
DialogWithSlots.Trigger.displayName = 'DialogTrigger';
interface DialogContentProps {
children: ReactNode;
className?: string;
onPointerDownOutside?: (event: Event) => void;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
}
DialogWithSlots.Content = function DialogContent({
children,
className,
onPointerDownOutside,
onEscapeKeyDown
}: DialogContentProps) {
const { isOpen, onOpenChange, modal } = useDialogContext();
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onEscapeKeyDown?.(e);
if (!e.defaultPrevented) {
onOpenChange(false);
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onOpenChange, onEscapeKeyDown]);
const handlePointerDownOutside = (e: Event) => {
onPointerDownOutside?.(e);
if (!e.defaultPrevented && modal) {
onOpenChange(false);
}
};
return (
<div
className={`dialog-content ${className || ''}`}
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
handlePointerDownOutside(e.nativeEvent);
}
}}
>
{children}
</div>
);
};
DialogWithSlots.Content.displayName = 'DialogContent';
// Portal component for rendering outside DOM hierarchy
interface DialogPortalProps {
children: ReactNode;
container?: Element;
}
DialogWithSlots.Portal = function DialogPortal({
children,
container
}: DialogPortalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return ReactDOM.createPortal(
children,
container || document.body
);
};
DialogWithSlots.Portal.displayName = 'DialogPortal';
// Overlay component
interface DialogOverlayProps {
className?: string;
children?: ReactNode;
}
DialogWithSlots.Overlay = function DialogOverlay({
className,
children
}: DialogOverlayProps) {
return (
<div className={`dialog-overlay ${className || ''}`}>
{children}
</div>
);
};
DialogWithSlots.Overlay.displayName = 'DialogOverlay';
// Advanced usage with complete flexibility
function FlexibleDialog() {
return (
<DialogWithSlots modal onOpenChange={(open) => console.log('Dialog:', open)}>
<DialogWithSlots.Trigger asChild>
<button className="custom-trigger">Open Advanced Dialog</button>
</DialogWithSlots.Trigger>
<DialogWithSlots.Portal>
<DialogWithSlots.Overlay className="custom-overlay" />
<DialogWithSlots.Content
className="custom-content"
onEscapeKeyDown={(e) => {
console.log('Escape pressed');
// Could prevent default to keep dialog open
}}
onPointerDownOutside={(e) => {
console.log('Clicked outside');
// Could prevent default to keep dialog open
}}
>
<h2>Advanced Dialog</h2>
<p>This dialog uses dynamic slots and advanced composition patterns.</p>
<DialogWithSlots.Trigger asChild>
<button>Close</button>
</DialogWithSlots.Trigger>
</DialogWithSlots.Content>
</DialogWithSlots.Portal>
</DialogWithSlots>
);
}
Conclusion and Recommendations
The Compound Pattern represents more than just a composition technique, it's a design philosophy that centers the developer experience and API flexibility. When fully mastered, it allows you to create components that are not only powerful but also delightful to use.
For Type Safety:
- Use generics to make your components reusable
- Implement proper error boundaries and validation
- Leverage TypeScript to create APIs that naturally guide correct usage
For Performance:
- Split contexts when state and actions update at different frequencies
- Use
useMemo
anduseCallback
strategically - Consider the impact of re-renders on complex component trees
For Architecture:
- Apply the pattern to naturally related component groups
- Maintain clear separation between state management and rendering logic
- Design for composition, not configuration
For Extensibility:
- Implement dynamic slots for maximum flexibility
- Allow context overrides at intermediate levels
- Create custom hooks for complex behaviors
The compound pattern transforms how components feel to use. Not just functional, but intuitive. Not just flexible, but inevitable.
You'll know you've got it right when someone extends your component months later and the solution feels obvious, when complex UIs emerge from simple composition. That's the mark of truly great component design.
Additional Resources
- React Compound Components Pattern
- Advanced React Patterns
- React Context API Documentation
- Radix UI Primitives
- ShadCN/UI Components
Happy coding!