React Compound Components
Introduction
Have you ever used a component library that provides components like Tabs
where you can write code like this?
<Tabs>
<TabList>
<Tab>First Tab</Tab>
<Tab>Second Tab</Tab>
</TabList>
<TabPanels>
<TabPanel>Content for first tab</TabPanel>
<TabPanel>Content for second tab</TabPanel>
</TabPanels>
</Tabs>
This is an example of the Compound Components pattern - one of the most elegant and flexible patterns for building React component APIs.
Compound components are a pattern where multiple components work together to form a cohesive unit. They share an implicit state that allows them to coordinate and communicate. This pattern provides a more declarative and flexible API compared to passing numerous props to a single component.
Why Use Compound Components?
Before diving into how to build compound components, let's understand why they're valuable:
- Flexible Markup Structure - Users can arrange child components as needed
- Clear Component Relationships - The parent-child structure makes component relationships obvious
- Encapsulated State Logic - Internal state is managed by the parent but accessible to children
- Semantic and Readable - Component composition mirrors HTML structure
- Reduced Prop Drilling - No need to pass many configuration props
Basic Example: Building a Toggle Component
Let's start with a simple example - a toggle component. First, let's see how it would be used:
<Toggle onToggle={(on) => console.log(on)}>
<Toggle.On>The button is on</Toggle.On>
<Toggle.Off>The button is off</Toggle.Off>
<Toggle.Button />
</Toggle>
Now, let's implement this:
import React, { createContext, useContext, useState } from 'react';
// Create a context to share state between components
const ToggleContext = createContext();
// Main Toggle component that manages state
function Toggle({ children, onToggle }) {
const [on, setOn] = useState(false);
const toggle = () => {
const newState = !on;
setOn(newState);
if (onToggle) {
onToggle(newState);
}
};
const value = { on, toggle };
return (
<ToggleContext.Provider value={value}>
{children}
</ToggleContext.Provider>
);
}
// Custom hook to use the Toggle context
function useToggle() {
const context = useContext(ToggleContext);
if (context === undefined) {
throw new Error('useToggle must be used within a Toggle component');
}
return context;
}
// Compound components
Toggle.On = function ToggleOn({ children }) {
const { on } = useToggle();
return on ? children : null;
};
Toggle.Off = function ToggleOff({ children }) {
const { on } = useToggle();
return on ? null : children;
};
Toggle.Button = function ToggleButton(props) {
const { on, toggle } = useToggle();
return (
<button
{...props}
onClick={toggle}
aria-pressed={on}
>
{on ? 'On' : 'Off'}
</button>
);
};
export default Toggle;
How It Works
- We create a context (
ToggleContext
) to share state between components - The main
Toggle
component manages state and provides it via context - We define subcomponents as static properties on the main component
- Each subcomponent accesses the shared state through the context
React Context vs React Children Mapping
There are two main ways to implement compound components in React:
- Using React Context (as shown above)
- Using React.Children.map
Let's explore the React.Children approach with the same Toggle example:
import React, { useState, cloneElement, Children } from 'react';
function Toggle({ children, onToggle }) {
const [on, setOn] = useState(false);
const toggle = () => {
const newState = !on;
setOn(newState);
if (onToggle) {
onToggle(newState);
}
};
return Children.map(children, child => {
// Return non-React elements as is
if (!React.isValidElement(child)) {
return child;
}
// Clone the element with additional props
return cloneElement(child, {
on,
toggle
});
});
}
function ToggleOn({ on, children }) {
return on ? children : null;
}
function ToggleOff({ on, children }) {
return on ? null : children;
}
function ToggleButton({ on, toggle, ...props }) {
return (
<button
{...props}
onClick={toggle}
aria-pressed={on}
>
{on ? 'On' : 'Off'}
</button>
);
}
Toggle.On = ToggleOn;
Toggle.Off = ToggleOff;
Toggle.Button = ToggleButton;
export default Toggle;
Comparing the Two Approaches
Context API Approach:
- ✅ Works with components at any depth (not just direct children)
- ✅ More explicit about component dependencies
- ⚠️ Slightly more code to set up
- ⚠️ Small performance cost for context
React.Children.map Approach:
- ✅ Simpler implementation for basic cases
- ✅ No context overhead
- ❌ Only works with direct children
- ❌ Less explicit about dependencies
The React Context approach is generally more flexible and is recommended for most use cases, especially as components grow in complexity.
Real-World Example: Building a Select Component
Let's build a more complex example - a custom Select component:
import React, { createContext, useContext, useState, useRef, useEffect } from 'react';
const SelectContext = createContext();
function Select({ children, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState(null);
const selectRef = useRef();
const registerOption = (option) => {
if (option.selected) {
setSelectedOption(option);
}
};
const selectOption = (option) => {
setSelectedOption(option);
setIsOpen(false);
if (onChange) {
onChange(option);
}
};
useEffect(() => {
const handleClickOutside = (event) => {
if (selectRef.current && !selectRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const value = {
isOpen,
selectedOption,
toggleOpen: () => setIsOpen(!isOpen),
registerOption,
selectOption
};
return (
<SelectContext.Provider value={value}>
<div ref={selectRef} className="select-container">
{children}
</div>
</SelectContext.Provider>
);
}
function useSelectContext() {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select compound components must be used within a Select component');
}
return context;
}
Select.Trigger = function SelectTrigger({ children }) {
const { selectedOption, toggleOpen, isOpen } = useSelectContext();
return (
<button
className="select-trigger"
onClick={toggleOpen}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
{selectedOption ? selectedOption.label : children}
<span className="arrow">{isOpen ? '▲' : '▼'}</span>
</button>
);
};
Select.Option = function SelectOption({ children, value, selected }) {
const { isOpen, selectOption, registerOption, selectedOption } = useSelectContext();
const isSelected = selectedOption && selectedOption.value === value;
useEffect(() => {
registerOption({ value, label: children, selected });
}, []);
if (!isOpen) return null;
return (
<div
className={`select-option ${isSelected ? 'selected' : ''}`}
onClick={() => selectOption({ value, label: children })}
role="option"
aria-selected={isSelected}
>
{children}
</div>
);
};
Select.OptionsList = function SelectOptionsList({ children }) {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return (
<div className="select-options" role="listbox">
{children}
</div>
);
};
export default Select;
And here's how you'd use it:
function App() {
return (
<div className="App">
<h1>Favorite Fruit Selector</h1>
<Select onChange={(option) => console.log(`Selected: ${option.label}`)}>
<Select.Trigger>Select a fruit</Select.Trigger>
<Select.OptionsList>
<Select.Option value="apple" selected>Apple</Select.Option>
<Select.Option value="banana">Banana</Select.Option>
<Select.Option value="orange">Orange</Select.Option>
<Select.Option value="grape">Grape</Select.Option>
</Select.OptionsList>
</Select>
</div>
);
}
Best Practices for Compound Components
-
Use clear naming conventions - Component names should clearly express their relationship (e.g.,
Accordion
andAccordionItem
) -
Provide sensible defaults - Make components work reasonably well without requiring excessive configuration
-
Include appropriate accessibility attributes - Build accessibility into your compound components (as shown in our examples)
-
Document the component API clearly - Ensure users understand how the components work together
-
Consider adding TypeScript support - For type safety and better developer experience:
type ToggleProps = {
children: React.ReactNode;
onToggle?: (on: boolean) => void;
};
type ToggleContextType = {
on: boolean;
toggle: () => void;
};
const ToggleContext = createContext<ToggleContextType | undefined>(undefined);
function Toggle({ children, onToggle }: ToggleProps) {
// Implementation...
}
// And so on...
-
Only expose what's necessary - Keep internal implementation details hidden
-
Use compound components when:
- Components have a natural parent-child relationship
- Multiple components need to share state
- You want to give users control over markup structure
- The pattern improves the readability of component usage
Common Pitfalls and Solutions
Nested Components Don't Access Context
Problem: Compound components that are nested deeper in the tree can't access the context.
Solution: Use the Context API approach instead of React.Children.map.
Performance Issues with Context
Problem: Using Context for every tiny component might cause unnecessary re-renders.
Solution:
- Consider using React.memo for performance-critical components
- Use the useCallback hook for functions passed through context
- Split context if there are distinct parts that change independently
Too Many Subcomponents
Problem: As your compound component grows, you might end up with many subcomponents.
Solution: Group related subcomponents into logical units or consider a different pattern like Slots.
Summary
The Compound Components pattern is a powerful way to build flexible and intuitive component APIs in React. It provides a natural way to express relationships between components while encapsulating complex state logic.
Key takeaways:
- Compound components use shared state via context or props cloning
- They give users control over component composition
- The pattern excels at expressing parent-child relationships
- React Context approach works better for complex cases
- React.Children approach is simpler but limited to direct children
Exercises
-
Extend the Toggle component to include a
Toggle.Count
component that displays the number of times the toggle has been switched. -
Convert the Select component example to use TypeScript.
-
Build a Tabs compound component with
Tabs
,TabList
,Tab
, andTabPanel
components. -
Create an Accordion compound component that allows multiple items to be open simultaneously when a certain prop is provided.
-
Try building a compound component that doesn't use React Context or React.Children - what are the limitations?
Additional Resources
- Kent C. Dodds' Advanced React Patterns course
- React Official Documentation on Context
- Ryan Florence's article on Compound Components
By using the Compound Components pattern effectively, you'll create more flexible, maintainable, and intuitive component APIs that your teammates and other developers will love working with.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)