Back to Blog
ReactPatternsComponentsAPI Design

Compound Components in React

Build flexible component APIs with compound components. From basic patterns to context to render props.

B
Bootspring Team
Engineering
November 24, 2021
6 min read

Compound components create flexible, expressive APIs. Here's how to build them effectively.

Basic Compound Pattern#

1// Simple compound component - Menu example 2interface MenuProps { 3 children: React.ReactNode; 4} 5 6interface MenuButtonProps { 7 children: React.ReactNode; 8 onClick?: () => void; 9} 10 11interface MenuListProps { 12 children: React.ReactNode; 13} 14 15interface MenuItemProps { 16 children: React.ReactNode; 17 onClick?: () => void; 18} 19 20function Menu({ children }: MenuProps) { 21 const [isOpen, setIsOpen] = useState(false); 22 23 return ( 24 <MenuContext.Provider value={{ isOpen, setIsOpen }}> 25 <div className="menu">{children}</div> 26 </MenuContext.Provider> 27 ); 28} 29 30function MenuButton({ children, onClick }: MenuButtonProps) { 31 const { setIsOpen } = useMenuContext(); 32 33 const handleClick = () => { 34 setIsOpen((prev) => !prev); 35 onClick?.(); 36 }; 37 38 return ( 39 <button className="menu-button" onClick={handleClick}> 40 {children} 41 </button> 42 ); 43} 44 45function MenuList({ children }: MenuListProps) { 46 const { isOpen } = useMenuContext(); 47 48 if (!isOpen) return null; 49 50 return <ul className="menu-list">{children}</ul>; 51} 52 53function MenuItem({ children, onClick }: MenuItemProps) { 54 const { setIsOpen } = useMenuContext(); 55 56 const handleClick = () => { 57 onClick?.(); 58 setIsOpen(false); 59 }; 60 61 return ( 62 <li className="menu-item" onClick={handleClick}> 63 {children} 64 </li> 65 ); 66} 67 68// Attach sub-components 69Menu.Button = MenuButton; 70Menu.List = MenuList; 71Menu.Item = MenuItem; 72 73// Usage 74<Menu> 75 <Menu.Button>Options</Menu.Button> 76 <Menu.List> 77 <Menu.Item onClick={() => console.log('Edit')}>Edit</Menu.Item> 78 <Menu.Item onClick={() => console.log('Delete')}>Delete</Menu.Item> 79 <Menu.Item onClick={() => console.log('Share')}>Share</Menu.Item> 80 </Menu.List> 81</Menu>

Context for State Sharing#

1// Create context with proper typing 2interface TabsContextType { 3 activeTab: string; 4 setActiveTab: (id: string) => void; 5} 6 7const TabsContext = createContext<TabsContextType | undefined>(undefined); 8 9function useTabsContext() { 10 const context = useContext(TabsContext); 11 if (!context) { 12 throw new Error('Tabs components must be used within a Tabs provider'); 13 } 14 return context; 15} 16 17// Main component 18interface TabsProps { 19 defaultValue: string; 20 value?: string; 21 onChange?: (value: string) => void; 22 children: React.ReactNode; 23} 24 25function Tabs({ defaultValue, value, onChange, children }: TabsProps) { 26 const [internalValue, setInternalValue] = useState(defaultValue); 27 28 const activeTab = value ?? internalValue; 29 30 const setActiveTab = (newValue: string) => { 31 setInternalValue(newValue); 32 onChange?.(newValue); 33 }; 34 35 return ( 36 <TabsContext.Provider value={{ activeTab, setActiveTab }}> 37 <div className="tabs">{children}</div> 38 </TabsContext.Provider> 39 ); 40} 41 42// Sub-components 43function TabList({ children }: { children: React.ReactNode }) { 44 return ( 45 <div className="tab-list" role="tablist"> 46 {children} 47 </div> 48 ); 49} 50 51interface TabProps { 52 value: string; 53 children: React.ReactNode; 54 disabled?: boolean; 55} 56 57function Tab({ value, children, disabled }: TabProps) { 58 const { activeTab, setActiveTab } = useTabsContext(); 59 const isActive = activeTab === value; 60 61 return ( 62 <button 63 role="tab" 64 aria-selected={isActive} 65 disabled={disabled} 66 className={`tab ${isActive ? 'active' : ''}`} 67 onClick={() => setActiveTab(value)} 68 > 69 {children} 70 </button> 71 ); 72} 73 74interface TabPanelProps { 75 value: string; 76 children: React.ReactNode; 77} 78 79function TabPanel({ value, children }: TabPanelProps) { 80 const { activeTab } = useTabsContext(); 81 82 if (activeTab !== value) return null; 83 84 return ( 85 <div role="tabpanel" className="tab-panel"> 86 {children} 87 </div> 88 ); 89} 90 91function TabPanels({ children }: { children: React.ReactNode }) { 92 return <div className="tab-panels">{children}</div>; 93} 94 95// Attach components 96Tabs.List = TabList; 97Tabs.Tab = Tab; 98Tabs.Panels = TabPanels; 99Tabs.Panel = TabPanel; 100 101// Usage 102<Tabs defaultValue="tab1" onChange={(value) => console.log(value)}> 103 <Tabs.List> 104 <Tabs.Tab value="tab1">First Tab</Tabs.Tab> 105 <Tabs.Tab value="tab2">Second Tab</Tabs.Tab> 106 <Tabs.Tab value="tab3" disabled>Disabled</Tabs.Tab> 107 </Tabs.List> 108 <Tabs.Panels> 109 <Tabs.Panel value="tab1">Content 1</Tabs.Panel> 110 <Tabs.Panel value="tab2">Content 2</Tabs.Panel> 111 <Tabs.Panel value="tab3">Content 3</Tabs.Panel> 112 </Tabs.Panels> 113</Tabs>

Flexible Accordion#

1interface AccordionContextType { 2 expandedItems: Set<string>; 3 toggleItem: (id: string) => void; 4 allowMultiple: boolean; 5} 6 7const AccordionContext = createContext<AccordionContextType | undefined>(undefined); 8 9interface AccordionProps { 10 children: React.ReactNode; 11 allowMultiple?: boolean; 12 defaultExpanded?: string[]; 13} 14 15function Accordion({ 16 children, 17 allowMultiple = false, 18 defaultExpanded = [], 19}: AccordionProps) { 20 const [expandedItems, setExpandedItems] = useState( 21 new Set(defaultExpanded) 22 ); 23 24 const toggleItem = (id: string) => { 25 setExpandedItems((prev) => { 26 const next = new Set(prev); 27 28 if (next.has(id)) { 29 next.delete(id); 30 } else { 31 if (!allowMultiple) { 32 next.clear(); 33 } 34 next.add(id); 35 } 36 37 return next; 38 }); 39 }; 40 41 return ( 42 <AccordionContext.Provider value={{ expandedItems, toggleItem, allowMultiple }}> 43 <div className="accordion">{children}</div> 44 </AccordionContext.Provider> 45 ); 46} 47 48interface AccordionItemProps { 49 value: string; 50 children: React.ReactNode; 51} 52 53const AccordionItemContext = createContext<{ value: string } | undefined>(undefined); 54 55function AccordionItem({ value, children }: AccordionItemProps) { 56 return ( 57 <AccordionItemContext.Provider value={{ value }}> 58 <div className="accordion-item">{children}</div> 59 </AccordionItemContext.Provider> 60 ); 61} 62 63function AccordionTrigger({ children }: { children: React.ReactNode }) { 64 const { expandedItems, toggleItem } = useContext(AccordionContext)!; 65 const { value } = useContext(AccordionItemContext)!; 66 const isExpanded = expandedItems.has(value); 67 68 return ( 69 <button 70 className="accordion-trigger" 71 aria-expanded={isExpanded} 72 onClick={() => toggleItem(value)} 73 > 74 {children} 75 <ChevronIcon className={isExpanded ? 'rotate-180' : ''} /> 76 </button> 77 ); 78} 79 80function AccordionContent({ children }: { children: React.ReactNode }) { 81 const { expandedItems } = useContext(AccordionContext)!; 82 const { value } = useContext(AccordionItemContext)!; 83 const isExpanded = expandedItems.has(value); 84 85 if (!isExpanded) return null; 86 87 return ( 88 <div className="accordion-content"> 89 {children} 90 </div> 91 ); 92} 93 94Accordion.Item = AccordionItem; 95Accordion.Trigger = AccordionTrigger; 96Accordion.Content = AccordionContent; 97 98// Usage 99<Accordion allowMultiple defaultExpanded={['item-1']}> 100 <Accordion.Item value="item-1"> 101 <Accordion.Trigger>Section 1</Accordion.Trigger> 102 <Accordion.Content>Content for section 1</Accordion.Content> 103 </Accordion.Item> 104 <Accordion.Item value="item-2"> 105 <Accordion.Trigger>Section 2</Accordion.Trigger> 106 <Accordion.Content>Content for section 2</Accordion.Content> 107 </Accordion.Item> 108</Accordion>

Slot Pattern#

1// Slot-based component 2interface SlotProps { 3 children: React.ReactNode; 4} 5 6interface CardSlots { 7 header?: React.ReactNode; 8 media?: React.ReactNode; 9 content?: React.ReactNode; 10 actions?: React.ReactNode; 11} 12 13function Card({ children }: { children: React.ReactNode }) { 14 const slots: CardSlots = {}; 15 16 React.Children.forEach(children, (child) => { 17 if (!React.isValidElement(child)) return; 18 19 switch (child.type) { 20 case CardHeader: 21 slots.header = child; 22 break; 23 case CardMedia: 24 slots.media = child; 25 break; 26 case CardContent: 27 slots.content = child; 28 break; 29 case CardActions: 30 slots.actions = child; 31 break; 32 } 33 }); 34 35 return ( 36 <div className="card"> 37 {slots.media} 38 {slots.header} 39 {slots.content} 40 {slots.actions} 41 </div> 42 ); 43} 44 45function CardHeader({ children }: SlotProps) { 46 return <div className="card-header">{children}</div>; 47} 48 49function CardMedia({ src, alt }: { src: string; alt: string }) { 50 return <img className="card-media" src={src} alt={alt} />; 51} 52 53function CardContent({ children }: SlotProps) { 54 return <div className="card-content">{children}</div>; 55} 56 57function CardActions({ children }: SlotProps) { 58 return <div className="card-actions">{children}</div>; 59} 60 61Card.Header = CardHeader; 62Card.Media = CardMedia; 63Card.Content = CardContent; 64Card.Actions = CardActions; 65 66// Order doesn't matter - slots are placed correctly 67<Card> 68 <Card.Actions> 69 <Button>Share</Button> 70 </Card.Actions> 71 <Card.Content> 72 <p>Card description</p> 73 </Card.Content> 74 <Card.Header> 75 <h3>Card Title</h3> 76 </Card.Header> 77 <Card.Media src="image.jpg" alt="Card image" /> 78</Card>

Render Props Variant#

1// Compound with render props for more control 2interface ToggleRenderProps { 3 isOn: boolean; 4 toggle: () => void; 5 setOn: () => void; 6 setOff: () => void; 7} 8 9interface ToggleProps { 10 children: (props: ToggleRenderProps) => React.ReactNode; 11 defaultOn?: boolean; 12} 13 14function Toggle({ children, defaultOn = false }: ToggleProps) { 15 const [isOn, setIsOn] = useState(defaultOn); 16 17 const toggle = () => setIsOn((prev) => !prev); 18 const setOn = () => setIsOn(true); 19 const setOff = () => setIsOn(false); 20 21 return <>{children({ isOn, toggle, setOn, setOff })}</>; 22} 23 24// Usage 25<Toggle> 26 {({ isOn, toggle }) => ( 27 <div> 28 <span>{isOn ? 'ON' : 'OFF'}</span> 29 <button onClick={toggle}>Toggle</button> 30 </div> 31 )} 32</Toggle>

Best Practices#

Design: ✓ Use context for state sharing ✓ Provide clear error messages ✓ Support controlled and uncontrolled ✓ Keep components focused API: ✓ Attach sub-components to parent ✓ Use descriptive component names ✓ Allow customization via props ✓ Document expected children Accessibility: ✓ Use proper ARIA attributes ✓ Support keyboard navigation ✓ Manage focus appropriately ✓ Test with screen readers

Conclusion#

Compound components create intuitive, flexible APIs. Use context for shared state, attach sub-components for discoverability, and provide both controlled and uncontrolled modes. This pattern scales well for complex UI components like tabs, accordions, and menus.

Share this article

Help spread the word about Bootspring