🧩 Compound Components Pattern
Welcome to one of React's most elegant design patterns! You've learned about components, hooks, and context - but what if you could create components that work together like a team, sharing state invisibly while giving users maximum flexibility? That's the power of compound components! Think of them like LEGO blocks that automatically know how to connect - you can arrange them however you want, but they still work together seamlessly. This pattern is used by popular libraries like React Router, Reach UI, and Material-UI. It's the secret sauce behind flexible, intuitive APIs that make developers say "Wow, this just works!" Let's learn how to build components that play nice together! 🎯
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand what compound components are and why they're useful
- Build compound components that share state implicitly
- Use Context API to enable communication between compound components
- Create flexible, composable component APIs
- Type compound components properly with TypeScript
- Implement real-world compound component patterns (Tabs, Accordion, Dropdown)
- Balance flexibility with developer experience
- Handle edge cases and provide helpful error messages
- Compare compound components with other composition patterns
Estimated Time: 60-75 minutes
Project: Build flexible Accordion, Tabs, and Select components
📑 In This Lesson
🎯 What Are Compound Components?
Compound components are a pattern where multiple components work together to form a complete, cohesive UI element, sharing state implicitly through context.
📖 Definition
Compound Components: A design pattern where a set of components work together, sharing implicit state through Context, to provide a flexible and intuitive API. The parent component manages state, while child components access it automatically.
Real-World Analogy
🎸 The Band Analogy
Think of a rock band:
- The Band (Parent Component): Provides the overall direction and tempo - everyone follows the same beat
- Individual Musicians (Child Components): Each plays their own part, but they all share the same rhythm and key
- Implicit Communication: Musicians don't need to explicitly tell each other the tempo - they all "feel" it
- Flexibility: You can arrange musicians in different orders, but they still play together harmoniously
That's exactly how compound components work - each child component knows how to coordinate with its siblings without explicit instructions!
Visual Example
Here's how compound components look in code:
// ✅ Compound Components - Flexible and Intuitive
<Tabs defaultValue="profile">
<TabList>
<Tab value="profile">Profile</Tab>
<Tab value="settings">Settings</Tab>
<Tab value="notifications">Notifications</Tab>
</TabList>
<TabPanel value="profile">
<h2>Your Profile</h2>
<p>Profile content here...</p>
</TabPanel>
<TabPanel value="settings">
<h2>Settings</h2>
<p>Settings content here...</p>
</TabPanel>
<TabPanel value="notifications">
<h2>Notifications</h2>
<p>Notifications content here...</p>
</TabPanel>
</Tabs>
// Notice:
// - Tab, TabPanel components "just know" which tab is active
// - No explicit state management from the user
// - Flexible arrangement - you can reorder components
// - Clean, declarative API
Compare with Traditional Approach
// ❌ Traditional Prop-based Approach - Rigid and Verbose
<Tabs
defaultValue="profile"
tabs={[
{ value: 'profile', label: 'Profile' },
{ value: 'settings', label: 'Settings' },
{ value: 'notifications', label: 'Notifications' }
]}
panels={[
{ value: 'profile', content: <ProfileContent /> },
{ value: 'settings', content: <SettingsContent /> },
{ value: 'notifications', content: <NotificationsContent /> }
]}
/>
// Problems:
// - Less flexible - all data must be in arrays
// - Harder to customize individual tabs/panels
// - Less intuitive - not declarative
// - Awkward to conditionally render tabs
Key Characteristics
| Characteristic | Description | Benefit |
|---|---|---|
| Implicit State Sharing | Child components access parent state via Context | No prop drilling, cleaner API |
| Flexible Composition | Components can be arranged in any order | Adapts to different use cases |
| Declarative API | UI structure matches component structure | Easy to understand and maintain |
| Separation of Concerns | Parent manages state, children render UI | Clear responsibilities |
How They Work
💡 The Magic
The "magic" of compound components is that child components automatically access parent state through Context, without the user needing to pass props explicitly. This makes the API feel natural and intuitive!
🤔 Why Use Compound Components?
Compound components solve several common problems in component design. Let's explore the benefits and when to use this pattern.
Problem 1: Prop Drilling
// ❌ Without Compound Components - Prop Drilling Hell
function Select({ value, onChange, options }: SelectProps) {
return (
<div>
<SelectTrigger value={value} />
<SelectDropdown
isOpen={isOpen}
value={value}
onChange={onChange}
onClose={() => setIsOpen(false)}
>
{options.map(opt => (
<SelectOption
key={opt.value}
value={opt.value}
isSelected={value === opt.value}
onClick={() => {
onChange(opt.value);
setIsOpen(false);
}}
>
{opt.label}
</SelectOption>
))}
</SelectDropdown>
</div>
);
}
// So much prop passing! And users can't even customize the structure!
// ✅ With Compound Components - Clean and Flexible
<Select value={selected} onChange={setSelected}>
<SelectTrigger />
<SelectDropdown>
<SelectOption value="red">Red</SelectOption>
<SelectOption value="blue">Blue</SelectOption>
<SelectOption value="green">Green</SelectOption>
</SelectDropdown>
</Select>
// All components automatically access value, onChange, etc. via Context!
// No prop drilling needed!
Problem 2: Inflexible APIs
Scenario: You need to customize a Tab component
With traditional props:
- Want an icon in one tab? Need to add iconPosition prop
- Want a badge? Need to add badge prop
- Want custom styling? Need to add className prop
- Soon you have 20+ props and it's still not flexible enough!
With compound components:
- Just put whatever you want inside <Tab>...</Tab>
- Icons, badges, tooltips - anything!
- Full control, minimal API surface
Problem 3: Poor Developer Experience
| Traditional Approach | Compound Components |
|---|---|
| Pass arrays of configuration objects | Write declarative JSX |
| Hard to conditionally render items | Use standard JavaScript conditions |
| Difficult to customize individual items | Customize each component directly |
| Need to learn custom configuration format | Already know JSX! |
Benefits of Compound Components
✅ Advantages
- Flexibility: Users can arrange and customize components however they need
- Intuitive API: Declarative JSX feels natural to React developers
- Minimal Prop Drilling: State shared implicitly through Context
- Extensibility: Easy to add new child components without breaking changes
- Separation of Concerns: Parent manages logic, children handle presentation
- Composability: Easy to combine with other patterns and components
- Type Safety: TypeScript can enforce component relationships
When to Use Compound Components
💡 Perfect Use Cases
- UI Controls: Tabs, Accordions, Dropdowns, Selects, Modals
- Navigation: Menus, Breadcrumbs, Pagination
- Layout Components: Split panes, Grids, Cards with headers/footers
- Forms: Form groups with labels, inputs, and error messages
- Data Display: Tables with sortable columns, expandable rows
- Interactive Widgets: Carousels, Steppers, Wizards
⚠️ When NOT to Use
- Simple components with few variants (overkill)
- Components that don't need shared state
- When the component structure should always be the same
- When performance is critical (Context can cause re-renders)
- When you need strict control over component order
Real-World Examples
Many popular libraries use compound components:
// React Router - Compound components for routing
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
// Reach UI - Accessible components
<Menu>
<MenuButton>Actions</MenuButton>
<MenuList>
<MenuItem onSelect={() => {}}>Download</MenuItem>
<MenuItem onSelect={() => {}}>Copy</MenuItem>
<MenuItem onSelect={() => {}}>Delete</MenuItem>
</MenuList>
</Menu>
// Radix UI - Headless UI components
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
🎯 Basic Compound Component Example
Let's build a simple compound component from scratch to understand the pattern. We'll create a Counter with separate buttons for increment and decrement.
Step 1: Create the Context
import { createContext, useContext, useState, ReactNode } from 'react';
// Define the shape of our shared state
interface CounterContextValue {
count: number;
increment: () => void;
decrement: () => void;
}
// Create the context
const CounterContext = createContext<CounterContextValue | undefined>(undefined);
// Custom hook to access the context
function useCounter() {
const context = useContext(CounterContext);
if (!context) {
throw new Error('Counter compound components must be used within Counter');
}
return context;
}
Step 2: Create the Parent Component
interface CounterProps {
children: ReactNode;
initialCount?: number;
}
function Counter({ children, initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const value: CounterContextValue = {
count,
increment,
decrement
};
return (
<CounterContext.Provider value={value}>
<div className="counter">
{children}
</div>
</CounterContext.Provider>
);
}
Step 3: Create Child Components
// Display Component
function CounterDisplay() {
const { count } = useCounter();
return <div className="counter-display">Count: {count}</div>;
}
// Increment Button
function CounterIncrement({ children }: { children: ReactNode }) {
const { increment } = useCounter();
return (
<button onClick={increment} className="counter-button">
{children || '+'}
</button>
);
}
// Decrement Button
function CounterDecrement({ children }: { children: ReactNode }) {
const { decrement } = useCounter();
return (
<button onClick={decrement} className="counter-button">
{children || '-'}
</button>
);
}
Step 4: Attach Components to Parent
// Attach child components as properties of parent
Counter.Display = CounterDisplay;
Counter.Increment = CounterIncrement;
Counter.Decrement = CounterDecrement;
// Export the compound component
export { Counter };
Step 5: Usage
// ✅ Basic usage
function App() {
return (
<Counter initialCount={10}>
<Counter.Display />
<Counter.Increment />
<Counter.Decrement />
</Counter>
);
}
// ✅ Flexible arrangement
function App2() {
return (
<Counter>
<Counter.Increment>Add One</Counter.Increment>
<Counter.Display />
<Counter.Decrement>Subtract One</Counter.Decrement>
</Counter>
);
}
// ✅ Custom layout
function App3() {
return (
<Counter>
<div className="counter-controls">
<Counter.Decrement />
<Counter.Display />
<Counter.Increment />
</div>
</Counter>
);
}
// Notice: No props needed! Components access state via Context.
Complete Example
// Full implementation in one place
import { createContext, useContext, useState, ReactNode } from 'react';
// Context
interface CounterContextValue {
count: number;
increment: () => void;
decrement: () => void;
}
const CounterContext = createContext<CounterContextValue | undefined>(undefined);
function useCounter() {
const context = useContext(CounterContext);
if (!context) {
throw new Error('Counter compound components must be used within Counter');
}
return context;
}
// Parent Component
interface CounterProps {
children: ReactNode;
initialCount?: number;
}
function Counter({ children, initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const value: CounterContextValue = {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1)
};
return (
<CounterContext.Provider value={value}>
<div className="counter">{children}</div>
</CounterContext.Provider>
);
}
// Child Components
Counter.Display = function CounterDisplay() {
const { count } = useCounter();
return <div className="counter-display">{count}</div>;
};
Counter.Increment = function CounterIncrement({
children = '+'
}: {
children?: ReactNode
}) {
const { increment } = useCounter();
return <button onClick={increment}>{children}</button>;
};
Counter.Decrement = function CounterDecrement({
children = '-'
}: {
children?: ReactNode
}) {
const { decrement } = useCounter();
return <button onClick={decrement}>{children}</button>;
};
export { Counter };
✅ Key Takeaways
- Parent component creates Context and provides state
- Child components consume Context via custom hook
- Child components attached as properties of parent (Counter.Display)
- Error thrown if child used outside parent (helpful for developers)
- Users get flexible, intuitive API without prop drilling
🔗 Implicit State Sharing with Context
The power of compound components comes from Context. Let's dive deeper into how to use Context effectively for implicit state sharing.
Understanding Implicit vs Explicit State
| Approach | How It Works | Example |
|---|---|---|
| Explicit (Props) | Parent passes state to each child via props | <Tab isActive={true} onClick={...} /> |
| Implicit (Context) | Children automatically access parent's context | <Tab /> {/* Knows if it's active! */} |
Context Pattern for Compound Components
import { createContext, useContext, ReactNode } from 'react';
// 1. Define the context value type
interface MyComponentContextValue {
// Shared state
isOpen: boolean;
selectedId: string | null;
// Shared actions
open: () => void;
close: () => void;
select: (id: string) => void;
}
// 2. Create the context with undefined default
const MyComponentContext = createContext<MyComponentContextValue | undefined>(undefined);
// 3. Create a custom hook with error checking
function useMyComponent() {
const context = useContext(MyComponentContext);
if (context === undefined) {
throw new Error(
'useMyComponent must be used within a MyComponent. ' +
'Make sure child components are wrapped in <MyComponent>...</MyComponent>'
);
}
return context;
}
// 4. Parent provides the context
function MyComponent({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const value: MyComponentContextValue = {
isOpen,
selectedId,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
select: (id) => setSelectedId(id)
};
return (
<MyComponentContext.Provider value={value}>
{children}
</MyComponentContext.Provider>
);
}
// 5. Children consume the context
function MyComponentChild() {
const { isOpen, open, close } = useMyComponent();
return (
<button onClick={isOpen ? close : open}>
{isOpen ? 'Close' : 'Open'}
</button>
);
}
Advanced Context Pattern: Separation of Concerns
For more complex components, separate state management from context provision:
// Custom hook for state logic
function useTabsState(defaultValue?: string) {
const [activeTab, setActiveTab] = useState(defaultValue);
const selectTab = useCallback((value: string) => {
setActiveTab(value);
}, []);
const isActive = useCallback((value: string) => {
return activeTab === value;
}, [activeTab]);
return {
activeTab,
selectTab,
isActive
};
}
// Context type
interface TabsContextValue {
activeTab: string | undefined;
selectTab: (value: string) => void;
isActive: (value: string) => boolean;
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
// Parent component
interface TabsProps {
children: ReactNode;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
}
function Tabs({ children, defaultValue, value, onChange }: TabsProps) {
const state = useTabsState(defaultValue);
// Controlled vs Uncontrolled
const activeTab = value !== undefined ? value : state.activeTab;
const selectTab = onChange !== undefined ? onChange : state.selectTab;
const contextValue: TabsContextValue = {
activeTab,
selectTab,
isActive: (val) => activeTab === val
};
return (
<TabsContext.Provider value={contextValue}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
Controlled vs Uncontrolled Compound Components
// Uncontrolled - Component manages its own state
function UncontrolledExample() {
return (
<Tabs defaultValue="home">
<TabList>
<Tab value="home">Home</Tab>
<Tab value="profile">Profile</Tab>
</TabList>
</Tabs>
);
}
// Controlled - Parent manages state
function ControlledExample() {
const [activeTab, setActiveTab] = useState('home');
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<TabList>
<Tab value="home">Home</Tab>
<Tab value="profile">Profile</Tab>
</TabList>
</Tabs>
);
}
// Hybrid - Support both patterns
function Tabs({ children, defaultValue, value, onChange }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
// Use external value if provided, otherwise internal
const currentValue = value !== undefined ? value : internalValue;
const handleChange = onChange !== undefined ? onChange : setInternalValue;
const contextValue = {
activeTab: currentValue,
selectTab: handleChange,
isActive: (val: string) => currentValue === val
};
return (
<TabsContext.Provider value={contextValue}>
{children}
</TabsContext.Provider>
);
}
Performance Optimization with Context
// ⚠️ Problem: Context value recreated every render
function TabsBad({ children }: { children: ReactNode }) {
const [activeTab, setActiveTab] = useState('home');
// New object every render! Causes all consumers to re-render
const value = {
activeTab,
selectTab: setActiveTab,
isActive: (val: string) => activeTab === val
};
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}
// ✅ Solution: Memoize context value
function TabsGood({ children }: { children: ReactNode }) {
const [activeTab, setActiveTab] = useState('home');
const value = useMemo(() => ({
activeTab,
selectTab: setActiveTab,
isActive: (val: string) => activeTab === val
}), [activeTab]);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}
// 🔥 Best: Memoize functions separately
function TabsBest({ children }: { children: ReactNode }) {
const [activeTab, setActiveTab] = useState('home');
const selectTab = useCallback((value: string) => {
setActiveTab(value);
}, []);
const isActive = useCallback((value: string) => {
return value === activeTab;
}, [activeTab]);
const value = useMemo(() => ({
activeTab,
selectTab,
isActive
}), [activeTab, selectTab, isActive]);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}
✅ Context Best Practices
- Always provide helpful error messages when context is undefined
- Memoize context value to prevent unnecessary re-renders
- Use useCallback for functions in context
- Support both controlled and uncontrolled modes when possible
- Keep context value as minimal as possible
- Consider splitting into multiple contexts for complex state
⚠️ Common Context Pitfalls
- Forgetting to memoize context value (causes unnecessary re-renders)
- Not checking if context is undefined in custom hook
- Putting too much state in one context (split it up!)
- Creating new functions in context value on every render
- Not supporting both controlled and uncontrolled modes
📑 Building a Tabs Component
Let's build a complete, production-ready Tabs component using the compound components pattern.
Planning the API
First, let's decide what our Tabs component should do:
// Desired usage
<Tabs defaultValue="profile">
<TabList>
<Tab value="profile">Profile</Tab>
<Tab value="settings">Settings</Tab>
<Tab value="notifications">Notifications</Tab>
</TabList>
<TabPanel value="profile">
Profile content
</TabPanel>
<TabPanel value="settings">
Settings content
</TabPanel>
<TabPanel value="notifications">
Notifications content
</TabPanel>
</Tabs>
Step 1: Context Setup
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
ReactNode
} from 'react';
// Context value type
interface TabsContextValue {
activeTab: string | undefined;
selectTab: (value: string) => void;
isActive: (value: string) => boolean;
}
// Create context
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
// Custom hook
function useTabs() {
const context = useContext(TabsContext);
if (context === undefined) {
throw new Error(
'Tabs compound components must be used within <Tabs>. ' +
'Wrap TabList, Tab, and TabPanel in <Tabs>...</Tabs>'
);
}
return context;
}
Step 2: Parent Tabs Component
interface TabsProps {
children: ReactNode;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
function Tabs({
children,
defaultValue,
value,
onChange,
className = ''
}: TabsProps) {
// Internal state for uncontrolled mode
const [internalValue, setInternalValue] = useState(defaultValue);
// Use controlled value if provided, otherwise use internal
const activeTab = value !== undefined ? value : internalValue;
// Memoized select function
const selectTab = useCallback((newValue: string) => {
if (onChange) {
onChange(newValue); // Controlled mode
} else {
setInternalValue(newValue); // Uncontrolled mode
}
}, [onChange]);
// Memoized active check
const isActive = useCallback((tabValue: string) => {
return activeTab === tabValue;
}, [activeTab]);
// Memoized context value
const contextValue = useMemo(() => ({
activeTab,
selectTab,
isActive
}), [activeTab, selectTab, isActive]);
return (
<TabsContext.Provider value={contextValue}>
<div className={`tabs ${className}`}>
{children}
</div>
</TabsContext.Provider>
);
}
Step 3: TabList Component
interface TabListProps {
children: ReactNode;
className?: string;
'aria-label'?: string;
}
function TabList({
children,
className = '',
'aria-label': ariaLabel = 'Tabs'
}: TabListProps) {
return (
<div
role="tablist"
aria-label={ariaLabel}
className={`tab-list ${className}`}
>
{children}
</div>
);
}
Step 4: Tab Component
interface TabProps {
children: ReactNode;
value: string;
disabled?: boolean;
className?: string;
}
function Tab({
children,
value,
disabled = false,
className = ''
}: TabProps) {
const { isActive, selectTab } = useTabs();
const active = isActive(value);
const handleClick = () => {
if (!disabled) {
selectTab(value);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled) {
selectTab(value);
}
}
};
return (
<button
role="tab"
aria-selected={active}
aria-controls={`panel-${value}`}
id={`tab-${value}`}
tabIndex={active ? 0 : -1}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={`tab ${active ? 'tab-active' : ''} ${disabled ? 'tab-disabled' : ''} ${className}`}
>
{children}
</button>
);
}
Step 5: TabPanel Component
interface TabPanelProps {
children: ReactNode;
value: string;
className?: string;
}
function TabPanel({
children,
value,
className = ''
}: TabPanelProps) {
const { isActive } = useTabs();
const active = isActive(value);
if (!active) {
return null; // Don't render inactive panels
}
return (
<div
role="tabpanel"
id={`panel-${value}`}
aria-labelledby={`tab-${value}`}
tabIndex={0}
className={`tab-panel ${className}`}
>
{children}
</div>
);
}
Step 6: Attach Components
// Attach child components to parent
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Export
export { Tabs };
export type { TabsProps, TabListProps, TabProps, TabPanelProps };
Complete Usage Example
import { Tabs } from './Tabs';
function UserProfile() {
return (
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Tab value="profile">
👤 Profile
</Tabs.Tab>
<Tabs.Tab value="settings">
⚙️ Settings
</Tabs.Tab>
<Tabs.Tab value="notifications">
🔔 Notifications
</Tabs.Tab>
<Tabs.Tab value="billing" disabled>
💳 Billing
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile">
<h2>Your Profile</h2>
<p>Manage your personal information</p>
</Tabs.Panel>
<Tabs.Panel value="settings">
<h2>Settings</h2>
<p>Customize your experience</p>
</Tabs.Panel>
<Tabs.Panel value="notifications">
<h2>Notifications</h2>
<p>Manage your notifications</p>
</Tabs.Panel>
<Tabs.Panel value="billing">
<h2>Billing</h2>
<p>Manage your subscription</p>
</Tabs.Panel>
</Tabs>
);
}
// Controlled mode
function ControlledTabs() {
const [activeTab, setActiveTab] = useState('profile');
useEffect(() => {
console.log('Active tab changed to:', activeTab);
}, [activeTab]);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
{/* ... same as above */}
</Tabs>
);
}
✅ Tabs Component Features
- ✅ Both controlled and uncontrolled modes
- ✅ Keyboard navigation support
- ✅ Accessible with proper ARIA attributes
- ✅ Disabled tabs support
- ✅ Flexible styling with className props
- ✅ Type-safe with TypeScript
- ✅ Performance optimized with useMemo and useCallback
- ✅ Helpful error messages
📂 Building an Accordion Component
Now let's build an Accordion component that allows multiple items to be expanded or enforces single expansion.
Planning the API
// Single expansion mode
<Accordion type="single" defaultValue="item-1">
<Accordion.Item value="item-1">
<Accordion.Trigger>What is React?</Accordion.Trigger>
<Accordion.Content>
React is a JavaScript library for building user interfaces.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>What is TypeScript?</Accordion.Trigger>
<Accordion.Content>
TypeScript is a typed superset of JavaScript.
</Accordion.Content>
</Accordion.Item>
</Accordion>
// Multiple expansion mode
<Accordion type="multiple" defaultValue={["item-1", "item-2"]}>
{/* Same structure */}
</Accordion>
Implementation
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
ReactNode
} from 'react';
// Context types
interface AccordionContextValue {
type: 'single' | 'multiple';
openItems: string[];
toggleItem: (value: string) => void;
isOpen: (value: string) => boolean;
}
const AccordionContext = createContext<AccordionContextValue | undefined>(undefined);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within <Accordion>');
}
return context;
}
// Item Context (for nested components)
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext = createContext<AccordionItemContextValue | undefined>(undefined);
function useAccordionItem() {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error('AccordionTrigger/Content must be used within AccordionItem');
}
return context;
}
// Parent Accordion Component
interface AccordionProps {
children: ReactNode;
type: 'single' | 'multiple';
defaultValue?: string | string[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
collapsible?: boolean; // For single mode - allow closing active item
}
function Accordion({
children,
type,
defaultValue,
value,
onChange,
collapsible = false
}: AccordionProps) {
// Normalize default value to array
const normalizedDefault = Array.isArray(defaultValue)
? defaultValue
: defaultValue
? [defaultValue]
: [];
const [internalValue, setInternalValue] = useState<string[]>(normalizedDefault);
// Get current open items
const openItems = value !== undefined
? (Array.isArray(value) ? value : [value])
: internalValue;
const toggleItem = useCallback((itemValue: string) => {
const newValue = type === 'single'
? (openItems.includes(itemValue) && collapsible
? [] // Close if already open and collapsible
: [itemValue]) // Open the new one
: (openItems.includes(itemValue)
? openItems.filter(v => v !== itemValue) // Remove from array
: [...openItems, itemValue]); // Add to array
if (onChange) {
onChange(type === 'single' ? newValue[0] || '' : newValue);
} else {
setInternalValue(newValue);
}
}, [type, openItems, onChange, collapsible]);
const isOpen = useCallback((itemValue: string) => {
return openItems.includes(itemValue);
}, [openItems]);
const contextValue = useMemo(() => ({
type,
openItems,
toggleItem,
isOpen
}), [type, openItems, toggleItem, isOpen]);
return (
<AccordionContext.Provider value={contextValue}>
<div className="accordion">
{children}
</div>
</AccordionContext.Provider>
);
}
// Item Component
interface AccordionItemProps {
children: ReactNode;
value: string;
className?: string;
}
function AccordionItem({ children, value, className = '' }: AccordionItemProps) {
const { isOpen } = useAccordion();
const open = isOpen(value);
const itemContextValue = useMemo(() => ({
value,
isOpen: open
}), [value, open]);
return (
<AccordionItemContext.Provider value={itemContextValue}>
<div className={`accordion-item ${open ? 'open' : ''} ${className}`}>
{children}
</div>
</AccordionItemContext.Provider>
);
}
// Trigger Component
interface AccordionTriggerProps {
children: ReactNode;
className?: string;
}
function AccordionTrigger({ children, className = '' }: AccordionTriggerProps) {
const { toggleItem } = useAccordion();
const { value, isOpen } = useAccordionItem();
return (
<button
type="button"
aria-expanded={isOpen}
aria-controls={`accordion-content-${value}`}
id={`accordion-trigger-${value}`}
onClick={() => toggleItem(value)}
className={`accordion-trigger ${className}`}
>
{children}
<span className={`accordion-icon ${isOpen ? 'open' : ''}`}>
{isOpen ? '−' : '+'}
</span>
</button>
);
}
// Content Component
interface AccordionContentProps {
children: ReactNode;
className?: string;
}
function AccordionContent({ children, className = '' }: AccordionContentProps) {
const { value, isOpen } = useAccordionItem();
return (
<div
id={`accordion-content-${value}`}
role="region"
aria-labelledby={`accordion-trigger-${value}`}
className={`accordion-content ${isOpen ? 'open' : 'closed'} ${className}`}
style={{ display: isOpen ? 'block' : 'none' }}
>
{children}
</div>
);
}
// Attach and export
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
export { Accordion };
Usage Examples
// Single expansion mode
function FAQSingle() {
return (
<Accordion type="single" defaultValue="q1" collapsible>
<Accordion.Item value="q1">
<Accordion.Trigger>How do I get started?</Accordion.Trigger>
<Accordion.Content>
<p>Getting started is easy! Just follow our setup guide.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="q2">
<Accordion.Trigger>Is there a free trial?</Accordion.Trigger>
<Accordion.Content>
<p>Yes! We offer a 14-day free trial with no credit card required.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="q3">
<Accordion.Trigger>How do I cancel?</Accordion.Trigger>
<Accordion.Content>
<p>You can cancel anytime from your account settings.</p>
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
// Multiple expansion mode
function FeaturesList() {
const [openItems, setOpenItems] = useState<string[]>(['feature-1']);
return (
<Accordion
type="multiple"
value={openItems}
onChange={setOpenItems}
>
<Accordion.Item value="feature-1">
<Accordion.Trigger>🚀 Performance</Accordion.Trigger>
<Accordion.Content>
Lightning-fast load times and smooth interactions.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-2">
<Accordion.Trigger>🔒 Security</Accordion.Trigger>
<Accordion.Content>
Enterprise-grade security with end-to-end encryption.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-3">
<Accordion.Trigger>📱 Mobile-First</Accordion.Trigger>
<Accordion.Content>
Optimized for mobile devices with responsive design.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
✅ Accordion Component Features
- ✅ Single or multiple expansion modes
- ✅ Collapsible option for single mode
- ✅ Both controlled and uncontrolled
- ✅ Nested context for items
- ✅ Accessible with ARIA attributes
- ✅ Smooth animations (via CSS)
- ✅ Flexible and composable
📘 Typing Compound Components
TypeScript makes compound components even more powerful by catching errors at compile time and providing excellent autocomplete. Let's explore advanced typing techniques.
Basic Component Typing
import { ReactNode, ReactElement } from 'react';
// Parent component props
interface MyComponentProps {
children: ReactNode;
defaultValue?: string;
onChange?: (value: string) => void;
}
// Child component props
interface MyComponentItemProps {
children: ReactNode;
value: string;
disabled?: boolean;
}
// Context value type
interface MyComponentContextValue {
selectedValue: string | undefined;
select: (value: string) => void;
isSelected: (value: string) => boolean;
}
Typing Attached Components
// Define the main component
function Select({ children }: { children: ReactNode }) {
// Implementation
return <div>{children}</div>;
}
// Define child components
function SelectTrigger({ children }: { children: ReactNode }) {
return <button>{children}</button>;
}
function SelectOption({ value, children }: { value: string; children: ReactNode }) {
return <div>{children}</div>;
}
// Create a type that includes the attached components
type SelectComponent = typeof Select & {
Trigger: typeof SelectTrigger;
Option: typeof SelectOption;
};
// Attach components
const TypedSelect = Select as SelectComponent;
TypedSelect.Trigger = SelectTrigger;
TypedSelect.Option = SelectOption;
// Export with proper types
export { TypedSelect as Select };
// Usage - TypeScript knows about attached components!
<Select>
<Select.Trigger /> {/* ✅ Autocomplete works! */}
<Select.Option value="1">Option 1</Select.Option>
</Select>
Generic Compound Components
// Generic select that works with any type
interface SelectProps<T> {
children: ReactNode;
value?: T;
onChange?: (value: T) => void;
defaultValue?: T;
}
interface SelectContextValue<T> {
selectedValue: T | undefined;
select: (value: T) => void;
isSelected: (value: T) => boolean;
}
// Create generic context
const SelectContext = createContext<SelectContextValue<any> | undefined>(undefined);
function Select<T>({ children, value, onChange, defaultValue }: SelectProps<T>) {
const [internalValue, setInternalValue] = useState<T | undefined>(defaultValue);
const selectedValue = value !== undefined ? value : internalValue;
const contextValue: SelectContextValue<T> = {
selectedValue,
select: (newValue: T) => {
onChange ? onChange(newValue) : setInternalValue(newValue);
},
isSelected: (checkValue: T) => selectedValue === checkValue
};
return (
<SelectContext.Provider value={contextValue}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
// Typed option component
interface SelectOptionProps<T> {
value: T;
children: ReactNode;
}
function SelectOption<T>({ value, children }: SelectOptionProps<T>) {
const context = useContext(SelectContext) as SelectContextValue<T>;
return (
<button onClick={() => context.select(value)}>
{children}
</button>
);
}
// Usage with type inference
function App() {
// TypeScript infers the type from defaultValue
return (
<Select<number> defaultValue={1}>
<SelectOption value={1}>One</SelectOption>
<SelectOption value={2}>Two</SelectOption>
{/* <SelectOption value="3">Three</SelectOption> */}
{/* ❌ Type error! Can't pass string */}
</Select>
);
}
Enforcing Component Relationships
import { Children, ReactElement, isValidElement } from 'react';
// Only allow specific children
interface TabListProps {
children: ReactElement<TabProps> | ReactElement<TabProps>[];
}
function TabList({ children }: TabListProps) {
// Validate children at runtime
const validChildren = Children.toArray(children).filter(child => {
if (!isValidElement(child)) return false;
// Check if child is a Tab component
return child.type === Tab;
});
if (validChildren.length === 0) {
console.warn('TabList should contain Tab components');
}
return <div role="tablist">{validChildren}</div>;
}
// More strict: Only accept Tab components
function StrictTabList({ children }: {
children: ReactElement<typeof Tab> | ReactElement<typeof Tab>[]
}) {
return <div role="tablist">{children}</div>;
}
Discriminated Union Types
// Different props based on mode
type AccordionSingleProps = {
type: 'single';
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
collapsible?: boolean;
children: ReactNode;
};
type AccordionMultipleProps = {
type: 'multiple';
value?: string[];
defaultValue?: string[];
onChange?: (value: string[]) => void;
children: ReactNode;
};
type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
function Accordion(props: AccordionProps) {
// TypeScript knows which props are available based on type
if (props.type === 'single') {
// props.value is string | undefined
// props.collapsible is available
const { value, defaultValue, onChange, collapsible } = props;
// ...
} else {
// props.value is string[] | undefined
// props.collapsible is NOT available
const { value, defaultValue, onChange } = props;
// ...
}
return <div>{props.children}</div>;
}
Utility Types for Props
import { ComponentPropsWithoutRef, ElementRef } from 'react';
// Extend native element props
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: 'primary' | 'secondary';
};
function CustomButton({ variant = 'primary', ...props }: ButtonProps) {
return <button className={`btn-${variant}`} {...props} />;
}
// Get the ref type of an element
type ButtonRef = ElementRef<'button'>; // HTMLButtonElement
// Use with forwardRef
const ForwardedButton = forwardRef<ButtonRef, ButtonProps>(
({ variant = 'primary', ...props }, ref) => {
return <button ref={ref} className={`btn-${variant}`} {...props} />;
}
);
// Omit specific props
type SelectTriggerProps = Omit<ButtonProps, 'onClick'> & {
// onClick is managed internally
};
// Pick specific props
type MinimalButtonProps = Pick<ButtonProps, 'children' | 'disabled'>;
Advanced: Slot-based Typing
// Define slots with specific types
interface CardSlots {
header?: ReactNode;
content: ReactNode;
footer?: ReactNode;
actions?: ReactElement<typeof Button> | ReactElement<typeof Button>[];
}
interface CardProps {
slots: CardSlots;
className?: string;
}
function Card({ slots, className }: CardProps) {
return (
<div className={`card ${className || ''}`}>
{slots.header && (
<div className="card-header">{slots.header}</div>
)}
<div className="card-content">{slots.content}</div>
{slots.footer && (
<div className="card-footer">{slots.footer}</div>
)}
{slots.actions && (
<div className="card-actions">{slots.actions}</div>
)}
</div>
);
}
// Usage with type checking
<Card
slots={{
header: <h2>Title</h2>,
content: <p>Content here</p>,
footer: <small>Footer text</small>,
actions: [
<Button key="1">Save</Button>,
<Button key="2">Cancel</Button>
]
}}
/>
✅ TypeScript Best Practices
- Use discriminated unions for different modes/variants
- Leverage generics for flexible, reusable components
- Extend native element types with ComponentPropsWithoutRef
- Type attached components properly for autocomplete
- Use utility types (Omit, Pick, Partial) to modify props
- Validate children at runtime when TypeScript can't
- Provide helpful JSDoc comments for complex types
🚀 Advanced Patterns
Let's explore some advanced patterns and techniques for building even more powerful compound components.
Pattern 1: Render Props with Compound Components
// Combine render props with compound components for max flexibility
interface DropdownProps {
children: ReactNode;
}
function Dropdown({ children }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownContext.Provider value={{ isOpen, setIsOpen }}>
<div className="dropdown">{children}</div>
</DropdownContext.Provider>
);
}
// Trigger with render prop for custom rendering
interface DropdownTriggerProps {
children: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
}
Dropdown.Trigger = function DropdownTrigger({ children }: DropdownTriggerProps) {
const { isOpen, setIsOpen } = useDropdown();
const toggle = () => setIsOpen(!isOpen);
return <>{children({ isOpen, toggle })}</>;
};
// Usage
<Dropdown>
<Dropdown.Trigger>
{({ isOpen, toggle }) => (
<button onClick={toggle}>
{isOpen ? '▲' : '▼'} Menu
</button>
)}
</Dropdown.Trigger>
<Dropdown.Menu>...</Dropdown.Menu>
</Dropdown>
Pattern 2: Polymorphic Components
// Component that can render as different elements
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
interface TabProps {
value: string;
disabled?: boolean;
}
type TabComponent = <C extends React.ElementType = 'button'>(
props: PolymorphicComponentProp<C, TabProps>
) => React.ReactElement | null;
const Tab: TabComponent = ({
as,
value,
disabled,
children,
...props
}) => {
const Component = as || 'button';
const { selectTab, isActive } = useTabs();
return (
<Component
role="tab"
aria-selected={isActive(value)}
disabled={disabled}
onClick={() => selectTab(value)}
{...props}
>
{children}
</Component>
);
};
// Usage - can render as different elements
<Tab value="home">Home</Tab> {/* button */}
<Tab as="a" href="#home" value="home">Home</Tab> {/* anchor */}
<Tab as="div" value="home">Home</Tab> {/* div */}
Pattern 3: Context Splitting for Performance
// Split context into state and actions to prevent unnecessary re-renders
// State context - changes frequently
interface TabsStateContextValue {
activeTab: string | undefined;
}
const TabsStateContext = createContext<TabsStateContextValue | undefined>(undefined);
// Actions context - stable, rarely changes
interface TabsActionsContextValue {
selectTab: (value: string) => void;
isActive: (value: string) => boolean;
}
const TabsActionsContext = createContext<TabsActionsContextValue | undefined>(undefined);
function Tabs({ children, defaultValue }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);
// State context value
const stateValue = useMemo(() => ({
activeTab
}), [activeTab]);
// Actions context value - doesn't change
const actionsValue = useMemo(() => ({
selectTab: setActiveTab,
isActive: (value: string) => activeTab === value
}), [activeTab]);
return (
<TabsStateContext.Provider value={stateValue}>
<TabsActionsContext.Provider value={actionsValue}>
{children}
</TabsActionsContext.Provider>
</TabsStateContext.Provider>
);
}
// Separate hooks
function useTabsState() {
const context = useContext(TabsStateContext);
if (!context) throw new Error('...');
return context;
}
function useTabsActions() {
const context = useContext(TabsActionsContext);
if (!context) throw new Error('...');
return context;
}
// Components only subscribe to what they need
function Tab({ value }: { value: string }) {
const { activeTab } = useTabsState(); // Re-renders when active tab changes
const { selectTab } = useTabsActions(); // Stable, doesn't cause re-renders
return (
<button onClick={() => selectTab(value)}>
{value} {activeTab === value && '✓'}
</button>
);
}
Pattern 4: Imperative Handle
import { useImperativeHandle, forwardRef } from 'react';
// Expose methods to parent
interface TabsHandle {
selectTab: (value: string) => void;
getActiveTab: () => string | undefined;
nextTab: () => void;
prevTab: () => void;
}
interface TabsProps {
children: ReactNode;
defaultValue?: string;
}
const Tabs = forwardRef<TabsHandle, TabsProps>(
({ children, defaultValue }, ref) => {
const [activeTab, setActiveTab] = useState(defaultValue);
const [tabs, setTabs] = useState<string[]>([]);
// Register tabs as they mount
const registerTab = useCallback((value: string) => {
setTabs(prev => [...prev, value]);
}, []);
// Expose methods to parent
useImperativeHandle(ref, () => ({
selectTab: setActiveTab,
getActiveTab: () => activeTab,
nextTab: () => {
const currentIndex = tabs.indexOf(activeTab || '');
const nextIndex = (currentIndex + 1) % tabs.length;
setActiveTab(tabs[nextIndex]);
},
prevTab: () => {
const currentIndex = tabs.indexOf(activeTab || '');
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
setActiveTab(tabs[prevIndex]);
}
}), [activeTab, tabs]);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, registerTab }}>
{children}
</TabsContext.Provider>
);
}
);
// Usage from parent
function App() {
const tabsRef = useRef<TabsHandle>(null);
return (
<div>
<button onClick={() => tabsRef.current?.nextTab()}>Next Tab</button>
<button onClick={() => tabsRef.current?.prevTab()}>Prev Tab</button>
<Tabs ref={tabsRef} defaultValue="home">
<Tab value="home">Home</Tab>
<Tab value="profile">Profile</Tab>
<Tab value="settings">Settings</Tab>
</Tabs>
</div>
);
}
Pattern 5: Compound Component with Slots
// Collect children into named slots
import { Children, ReactElement, isValidElement } from 'react';
interface ModalProps {
children: ReactNode;
isOpen: boolean;
onClose: () => void;
}
function Modal({ children, isOpen, onClose }: ModalProps) {
// Extract different child types into slots
const slots = {
header: null as ReactNode,
body: null as ReactNode,
footer: null as ReactNode
};
Children.forEach(children, (child) => {
if (isValidElement(child)) {
if (child.type === ModalHeader) {
slots.header = child;
} else if (child.type === ModalBody) {
slots.body = child;
} else if (child.type === ModalFooter) {
slots.footer = child;
}
}
});
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
{slots.header && <div className="modal-header">{slots.header}</div>}
{slots.body && <div className="modal-body">{slots.body}</div>}
{slots.footer && <div className="modal-footer">{slots.footer}</div>}
</div>
</div>
);
}
function ModalHeader({ children }: { children: ReactNode }) {
return <>{children}</>;
}
function ModalBody({ children }: { children: ReactNode }) {
return <>{children}</>;
}
function ModalFooter({ children }: { children: ReactNode }) {
return <>{children}</>;
}
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
// Usage - order doesn't matter!
<Modal isOpen={true} onClose={close}>
<Modal.Footer>
<button>Close</button>
</Modal.Footer>
<Modal.Header>
<h2>Confirm</h2>
</Modal.Header>
<Modal.Body>
<p>Are you sure?</p>
</Modal.Body>
</Modal>
💡 Advanced Pattern Use Cases
- Render Props: Maximum flexibility for custom rendering
- Polymorphic: Components that can render as different elements
- Context Splitting: Optimize performance by separating state and actions
- Imperative Handle: Parent controls compound component programmatically
- Slots: Children can be in any order, automatically organized
🏋️ Hands-on Practice
Time to apply what you've learned! Complete these exercises to master compound components.
🏋️ Exercise 1: Build a Select Component
Goal: Create a compound Select component with dropdown functionality.
Requirements:
- Select, SelectTrigger, SelectOption components
- Show/hide dropdown on trigger click
- Close dropdown when option selected
- Display selected value in trigger
- Support controlled and uncontrolled modes
💡 Hint
You'll need context for: isOpen, selected value, select function, and toggle function. Don't forget to close the dropdown when clicking outside!
✅ Solution
const SelectContext = createContext<{
isOpen: boolean;
toggle: () => void;
selectedValue: string | undefined;
select: (value: string) => void;
} | undefined>(undefined);
function useSelect() {
const context = useContext(SelectContext);
if (!context) throw new Error('Must be used within Select');
return context;
}
function Select({
children,
value,
onChange,
defaultValue
}: {
children: ReactNode;
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue);
const selectedValue = value !== undefined ? value : internalValue;
const contextValue = useMemo(() => ({
isOpen,
toggle: () => setIsOpen(!isOpen),
selectedValue,
select: (newValue: string) => {
onChange ? onChange(newValue) : setInternalValue(newValue);
setIsOpen(false);
}
}), [isOpen, selectedValue, onChange]);
return (
<SelectContext.Provider value={contextValue}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
Select.Trigger = function SelectTrigger({ children }: { children?: ReactNode }) {
const { toggle, selectedValue } = useSelect();
return (
<button onClick={toggle}>
{children || selectedValue || 'Select...'}
</button>
);
};
Select.Dropdown = function SelectDropdown({ children }: { children: ReactNode }) {
const { isOpen } = useSelect();
if (!isOpen) return null;
return <div className="dropdown">{children}</div>;
};
Select.Option = function SelectOption({
value,
children
}: {
value: string;
children: ReactNode
}) {
const { select, selectedValue } = useSelect();
return (
<button
onClick={() => select(value)}
className={selectedValue === value ? 'selected' : ''}
>
{children}
</button>
);
};
🏋️ Exercise 2: Build a Card Component
Goal: Create a flexible Card compound component with header, body, and footer.
Requirements:
- Card, Card.Header, Card.Body, Card.Footer components
- Optional collapse/expand functionality
- Context to share collapsible state
- Support cards without headers or footers
✅ Solution
const CardContext = createContext<{
isCollapsed: boolean;
toggleCollapse: () => void;
collapsible: boolean;
} | undefined>(undefined);
function useCard() {
const context = useContext(CardContext);
if (!context) throw new Error('Must be used within Card');
return context;
}
function Card({
children,
collapsible = false,
defaultCollapsed = false
}: {
children: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
}) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const contextValue = useMemo(() => ({
isCollapsed,
toggleCollapse: () => setIsCollapsed(!isCollapsed),
collapsible
}), [isCollapsed, collapsible]);
return (
<CardContext.Provider value={contextValue}>
<div className="card">{children}</div>
</CardContext.Provider>
);
}
Card.Header = function CardHeader({ children }: { children: ReactNode }) {
const { collapsible, isCollapsed, toggleCollapse } = useCard();
return (
<div className="card-header">
{children}
{collapsible && (
<button onClick={toggleCollapse}>
{isCollapsed ? '▼' : '▲'}
</button>
)}
</div>
);
};
Card.Body = function CardBody({ children }: { children: ReactNode }) {
const { isCollapsed } = useCard();
if (isCollapsed) return null;
return <div className="card-body">{children}</div>;
};
Card.Footer = function CardFooter({ children }: { children: ReactNode }) {
const { isCollapsed } = useCard();
if (isCollapsed) return null;
return <div className="card-footer">{children}</div>;
};
🏋️ Exercise 3: Build a Menu Component
Goal: Create a dropdown menu with keyboard navigation.
Requirements:
- Menu, Menu.Button, Menu.Items, Menu.Item components
- Open/close on button click
- Close on item selection or outside click
- Arrow key navigation between items
- Enter key to select focused item
💡 Hint
Use refs to track menu items and manage focus. Track focused index in state for keyboard navigation.
🏋️ Challenge: Build a Stepper/Wizard Component
Goal: Create a multi-step wizard with navigation.
Requirements:
- Stepper, Stepper.Step components
- Track current step
- Next/Previous navigation methods
- Validation before proceeding
- Optional completion callback
- Display step indicators
✅ Solution Skeleton
interface StepperContextValue {
currentStep: number;
totalSteps: number;
goToStep: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
canGoNext: boolean;
canGoPrev: boolean;
}
function Stepper({
children,
onComplete
}: {
children: ReactNode;
onComplete?: () => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const steps = Children.toArray(children);
const totalSteps = steps.length;
// Implement context and navigation logic
// ...
return (
<StepperContext.Provider value={contextValue}>
<div className="stepper">
<StepIndicators />
<div className="step-content">
{steps[currentStep]}
</div>
<StepNavigation />
</div>
</StepperContext.Provider>
);
}
✅ Best Practices
Follow these guidelines to build excellent compound components that are flexible, maintainable, and performant.
✅ Do's
- Provide helpful error messages: Tell developers exactly what went wrong and how to fix it
- Support both controlled and uncontrolled modes: Give users flexibility in how they manage state
- Memoize context values: Prevent unnecessary re-renders with useMemo and useCallback
- Use proper TypeScript types: Make your components type-safe and provide great autocomplete
- Add accessibility features: ARIA attributes, keyboard navigation, focus management
- Document your API: Clear examples and JSDoc comments help developers understand usage
- Keep context minimal: Only share what's necessary between components
- Name components clearly: Parent.Child naming makes relationships obvious
- Provide sensible defaults: Make the simple case simple, complex case possible
- Test with real use cases: Ensure your API works for actual scenarios
❌ Don'ts
- Don't overuse the pattern: Not every component needs to be compound
- Don't forget to memoize: Unmemoized context causes unnecessary re-renders
- Don't make the API too flexible: Too many options create confusion
- Don't put everything in one context: Split state and actions for better performance
- Don't ignore accessibility: Compound components should be accessible by default
- Don't skip error handling: Helpful errors save developers hours of debugging
- Don't break composition: Allow custom wrappers and styling
- Don't forget about SSR: Ensure components work with server-side rendering
- Don't create circular dependencies: Keep context dependencies clear
- Don't sacrifice type safety: Use proper TypeScript even if it's more complex
💡 Pro Tips
- Start simple, add complexity as needed: Build the basic version first
- Look at popular libraries: Study Radix UI, Reach UI, Headless UI for inspiration
- Use render props for ultimate flexibility: Combine with compound components when needed
- Consider polymorphic 'as' prop: Let components render as different elements
- Provide escape hatches: Allow users to override default behavior
- Document component relationships: Make it clear which components work together
- Test keyboard navigation: Ensure components work without a mouse
- Version your API carefully: Breaking changes in compound components affect many users
- Consider mobile interactions: Touch gestures, smaller screens
- Profile performance: Use React DevTools to identify bottlenecks
📋 Compound Component Checklist
Before Publishing Your Component
- ☐ Context throws helpful error when used incorrectly
- ☐ Context value is memoized to prevent re-renders
- ☐ Supports both controlled and uncontrolled modes
- ☐ All child components properly typed
- ☐ ARIA attributes for accessibility
- ☐ Keyboard navigation works correctly
- ☐ Focus management is intuitive
- ☐ Components can be styled with className
- ☐ Works with React DevTools
- ☐ Documented with examples
- ☐ Tested with real use cases
- ☐ Works with SSR (if applicable)
🎯 API Design Principles
Creating Intuitive APIs
- Make it obvious: API should be self-explanatory
- Minimize required props: Sensible defaults for everything
- Fail fast with helpful errors: Don't let bugs hide
- Be consistent: Similar components should work similarly
- Optimize for the common case: Simple things should be simple
- Provide escape hatches: Complex things should be possible
- Think about composition: Components should work well together
🔧 Maintenance Tips
- Version carefully: Compound components have more surface area for breaking changes
- Deprecate gradually: Give users time to migrate
- Keep examples updated: Documentation is crucial
- Monitor usage patterns: See how people actually use your components
- Gather feedback: Users will tell you what's confusing
- Write integration tests: Test how components work together
⚠️ Common Mistakes to Avoid
Learn from these common pitfalls so you don't have to make them yourself!
Mistake 1: Forgetting to Memoize Context
// ❌ Bad - new object every render
function Tabs({ children }: { children: ReactNode }) {
const [activeTab, setActiveTab] = useState('home');
// This creates a new object on every render!
// All consumers re-render unnecessarily
const value = {
activeTab,
setActiveTab
};
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}
// ✅ Good - memoized value
function Tabs({ children }: { children: ReactNode }) {
const [activeTab, setActiveTab] = useState('home');
const value = useMemo(() => ({
activeTab,
setActiveTab
}), [activeTab]);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}
Mistake 2: Poor Error Messages
// ❌ Bad - unhelpful error
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Error'); // What error?!
}
return context;
}
// ✅ Good - clear, actionable error
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
'useTabs must be used within a <Tabs> component. ' +
'Wrap your Tab, TabList, and TabPanel components in ' +
'<Tabs>...</Tabs> to fix this error.'
);
}
return context;
}
Mistake 3: Not Supporting Controlled Mode
// ❌ Bad - only uncontrolled
function Tabs({ children, defaultValue }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);
// Can't be controlled from parent!
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
// ✅ Good - supports both modes
function Tabs({ children, defaultValue, value, onChange }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
// Use controlled value if provided
const activeTab = value !== undefined ? value : internalValue;
const setActiveTab = onChange || setInternalValue;
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
Mistake 4: Tight Coupling
// ❌ Bad - can't customize structure
function Tabs({ children }: { children: ReactNode }) {
return (
<div className="tabs">
<div className="tab-list">
{/* Hardcoded - no flexibility! */}
{children}
</div>
<div className="tab-panels">
{/* Where do panels go? */}
</div>
</div>
);
}
// ✅ Good - flexible composition
function Tabs({ children }: { children: ReactNode }) {
return (
<TabsContext.Provider value={contextValue}>
<div className="tabs">
{children} {/* User controls structure */}
</div>
</TabsContext.Provider>
);
}
Mistake 5: Missing TypeScript Types
// ❌ Bad - loose typing
function Tabs({ children }: any) {
// ...
}
Tabs.Tab = function Tab({ value, children }: any) {
// ...
};
// ✅ Good - proper types
interface TabsProps {
children: ReactNode;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
}
function Tabs({ children, defaultValue, value, onChange }: TabsProps) {
// ...
}
interface TabProps {
value: string;
children: ReactNode;
disabled?: boolean;
}
Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
// ...
};
Mistake 6: Ignoring Accessibility
// ❌ Bad - no accessibility
function Tab({ value, children }: TabProps) {
const { selectTab } = useTabs();
return (
<div onClick={() => selectTab(value)}>
{children}
</div>
);
}
// ✅ Good - accessible
function Tab({ value, children }: TabProps) {
const { selectTab, isActive } = useTabs();
const active = isActive(value);
return (
<button
role="tab"
aria-selected={active}
aria-controls={`panel-${value}`}
id={`tab-${value}`}
tabIndex={active ? 0 : -1}
onClick={() => selectTab(value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectTab(value);
}
}}
>
{children}
</button>
);
}
Mistake 7: Too Much Context State
// ❌ Bad - everything in one context
interface MegaContextValue {
tabs: Tab[];
activeTab: string;
hoveredTab: string | null;
focusedTab: string | null;
tabPositions: Record<string, DOMRect>;
animationState: 'idle' | 'animating';
// ... 20 more properties
}
// ✅ Good - split into multiple contexts
interface TabsStateContext {
activeTab: string;
}
interface TabsActionsContext {
selectTab: (value: string) => void;
}
interface TabsMetadataContext {
tabs: Tab[];
registerTab: (tab: Tab) => void;
}
⚠️ Remember
- Always memoize context values
- Provide clear, actionable error messages
- Support both controlled and uncontrolled modes
- Keep components loosely coupled and composable
- Use proper TypeScript types for safety
- Build accessibility in from the start
- Split large contexts into smaller ones
📚 Summary
🎉 Key Takeaways
- Compound components are multiple components that work together by sharing implicit state through Context
- They provide flexible, intuitive APIs that feel natural to React developers
- Context enables implicit state sharing without prop drilling
- Support both modes: Controlled (parent manages state) and uncontrolled (component manages state)
- Memoize context values to prevent unnecessary re-renders
- TypeScript makes them safer with proper types and autocomplete
- Common use cases: Tabs, Accordions, Dropdowns, Menus, Modals
- Advanced patterns: Render props, polymorphic components, context splitting
- Accessibility matters: ARIA attributes, keyboard navigation, focus management
- Balance flexibility with simplicity - make simple things simple, complex things possible
📊 Compound Components vs Alternatives
| Pattern | Best For | Pros | Cons |
|---|---|---|---|
| Compound Components | Flexible UI controls | Intuitive API, flexible composition | More setup, Context overhead |
| Props-based | Simple, fixed components | Simple, explicit | Less flexible, prop drilling |
| Render Props | Maximum flexibility | Ultimate control | Complex, harder to read |
| Hooks-based | Headless components | Separation of logic/UI | Users build everything |
🎯 When to Use Compound Components
✅ Perfect Use Cases
- UI controls with multiple related parts (Tabs, Accordions, Selects)
- Components where users need layout flexibility
- When you want declarative, JSX-based APIs
- Building component libraries or design systems
- Components with shared internal state
❌ Not Ideal When
- Component is simple with few variants
- Structure should always be the same
- No need for shared state between children
- Performance is extremely critical (Context overhead)
- Simple props-based approach is sufficient
🔑 Key Patterns Review
1. Basic Pattern
// Context → Parent provides → Children consume
const Context = createContext(undefined);
function Parent({ children }) {
return <Context.Provider value={...}>{children}</Context.Provider>;
}
Parent.Child = function Child() {
const context = useContext(Context);
return ...;
};
2. Controlled/Uncontrolled
// Support both by checking if value/onChange provided
const activeValue = value !== undefined ? value : internalValue;
const handleChange = onChange || setInternalValue;
3. Performance Optimization
// Memoize context value
const value = useMemo(() => ({
state,
actions: useCallback(...)
}), [dependencies]);
🎓 What You've Learned
Congratulations! You now understand:
- ✅ What compound components are and why they're useful
- ✅ How to use Context for implicit state sharing
- ✅ Building real-world components: Tabs, Accordion
- ✅ TypeScript typing for compound components
- ✅ Advanced patterns: render props, polymorphic, context splitting
- ✅ Best practices for API design and maintainability
- ✅ Common mistakes and how to avoid them
- ✅ Accessibility considerations
📚 Additional Resources
- Kent C. Dodds - Compound Components with React Hooks
- Radix UI - Unstyled, accessible components
- Reach UI - Accessible React components
- Headless UI - Unstyled, accessible UI components
- Patterns.dev - Compound Pattern
- React TypeScript Cheatsheet - Compound Components
🚀 What's Next?
Congratulations on completing Module 5: Advanced Hooks and Patterns! 🎉
You've mastered:
- useReducer for complex state management
- useContext for sharing data across components
- useRef for DOM access and mutable values
- useMemo and useCallback for performance optimization
- Compound Components pattern for flexible APIs
In Module 6: Routing and Navigation, you'll learn how to build multi-page applications with React Router, including:
- Single Page Applications (SPAs)
- Client-side routing
- Dynamic routes and parameters
- Protected routes and navigation guards
- And building a complete multi-page blog application!
🎯 Practice Challenge
Before moving on, try building one of these projects using compound components:
- Form Builder: Create a flexible form component with Form, Form.Field, Form.Label, Form.Input, Form.Error
- Data Table: Build a sortable, filterable table with Table, Table.Header, Table.Row, Table.Cell
- Navigation Menu: Create a multi-level menu with Menu, Menu.Item, Menu.Submenu
- Dialog System: Build modals with Dialog, Dialog.Trigger, Dialog.Content, Dialog.Actions
- Timeline Component: Create a timeline with Timeline, Timeline.Item, Timeline.Marker
💡 Final Thoughts
Compound components are a powerful pattern that balances flexibility with simplicity. They make your component APIs feel natural and intuitive while giving users the freedom to compose components however they need.
Key principles to remember:
- ✅ Make simple things simple (good defaults)
- ✅ Make complex things possible (flexibility)
- ✅ Fail fast with helpful errors (great DX)
- ✅ Be consistent (predictable APIs)
- ✅ Think about accessibility (inclusive by default)
This pattern is used by all major React component libraries for a reason - it works! Now you have the knowledge to create amazing, flexible components. Go build something awesome! 🚀
🎉 Congratulations!
You've completed Module 5 and mastered advanced React patterns! You now know how to build flexible, composable components with elegant APIs. You understand compound components, performance optimization, advanced hooks, and how to create components that developers love to use. These are professional-level skills that will serve you well in any React project. You're now ready to tackle complex UI challenges with confidence! Keep up the incredible work! 🌟