Skip to main content

🧩 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?

You already know how to build a single component that does one job. But some UI elements are really a family of parts that only make sense together: a set of tabs and the panels they reveal, an accordion and its expandable sections, a dropdown and its menu items. Compound components are the pattern for building those families β€” several components that work together to form one cohesive control, quietly sharing state through Context instead of forcing the person using them to wire it all up by hand.

Here's the key mental shift: the person consuming your component writes plain, readable JSX, arranges the pieces however they like, and the pieces just know how to talk to each other. No isActive prop threaded through five layers, no configuration array β€” the coordination happens behind the scenes.

πŸ“– 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 owns the state; the children read from it automatically, so the consumer composes the UI declaratively without passing state around by hand.

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">
    <Tabs.List>
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        <Tabs.Tab value="settings">Settings</Tabs.Tab>
        <Tabs.Tab value="notifications">Notifications</Tabs.Tab>
    </Tabs.List>

    <Tabs.Panel value="profile">
        <h2>Your Profile</h2>
        <p>Profile content here...</p>
    </Tabs.Panel>

    <Tabs.Panel value="settings">
        <h2>Settings</h2>
        <p>Settings content here...</p>
    </Tabs.Panel>

    <Tabs.Panel value="notifications">
        <h2>Notifications</h2>
        <p>Notifications content here...</p>
    </Tabs.Panel>
</Tabs>

// Notice:
// - Tabs.Tab and Tabs.Panel "just know" which tab is active
// - No explicit state management from the consumer
// - Flexible arrangement - you can reorder components freely
// - Clean, declarative API - the JSX mirrors the UI
// - The dotted Tabs.Tab naming signals "these belong together"

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

graph TD A[Parent Component] --> B[Creates Context] B --> C[Manages State] C --> D[Provides State via Context] D --> E[Child Component 1] D --> F[Child Component 2] D --> G[Child Component 3] E --> H[Consumes Context] F --> I[Consumes Context] G --> J[Consumes Context] H --> K[Accesses Shared State] I --> K J --> K style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style K fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff
Anatomy of a Compound Component The Tabs parent holds a Context Provider; TabList, Tab, and TabPanel children read the shared active-tab value from it. <Tabs> (parent) owns state Β· provides Context TabsContext.Provider β†’ { activeTab, selectTab } <Tabs.List> β†’ <Tabs.Tab value="a" /> … <Tabs.Panel value="a"> … </Tabs.Panel> children read the same Context β€” no props threaded
Figure 1: The parent owns the state and wraps its children in a Context Provider. Each child β€” Tabs.Tab, Tabs.Panel β€” reads the shared value directly, so nothing is passed down by hand.

πŸ’‘ The Magic (and how it's really done)

The "magic" is that child components automatically read parent state through Context, without the consumer passing props explicitly. That makes the API feel natural and intuitive.

Historically, some libraries achieved this by walking props.children and injecting props with React.cloneElement. That still works, but it's fragile β€” it only reaches direct children, breaks the moment someone wraps a child in a <div>, and hides data flow in a way that's hard to type. In modern React (18/19), Context is the preferred mechanism. It reaches any descendant at any depth, survives arbitrary wrapping, and types cleanly. We'll use Context throughout this lesson and revisit cloneElement only to explain why we avoid it.

πŸ€” 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

Before we tackle a full Tabs widget, let's build the smallest compound component that still shows every moving part. We'll make a Counter whose display and buttons are separate components β€” Counter.Display, Counter.Increment, Counter.Decrement β€” that all share one count through Context. It's deliberately tiny so you can see the four-step recipe (create context β†’ build the parent provider β†’ build the children β†’ attach them) with nothing to distract you. Every compound component in this lesson follows this same recipe.

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>
    );
}

See the Context Guard in Action

That if (context === undefined) throw… check isn't busywork β€” it's the difference between a crisp, actionable error and a baffling Cannot read properties of undefined crash deep in a child. Click each usage below to see what the guard reports.

function useTabs() β†’ reads TabsContext, throws if it's undefined

πŸ‘† Click a usage above to see what the Context guard does.

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>
    );
}

The trick that makes one component serve both modes is a single question asked on every render: "did the caller hand me a value prop?" If yes, the caller is in charge (controlled); if not, the component falls back to its own state (uncontrolled). Here's that decision as a flow:

graph TD A["Tabs renders"] --> B{"Is a value prop passed?"} B -->|Yes| C["Controlled: parent state is the source of truth"] B -->|No| D["Uncontrolled: internal useState is the source of truth"] C --> E["selectTab calls onChange(next)"] D --> F["selectTab calls setInternalValue(next)"] E --> G["Children read activeTab via Context"] F --> G style B fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style G fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff

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

Tabs are the "hello world" of compound components, and for good reason: they have exactly the shape the pattern was made for. A list of clickable tabs and a set of panels, where clicking one tab reveals one panel β€” and every piece needs to agree on a single fact: which tab is active? That shared fact is the perfect thing to put in Context. Let's build a complete, accessible, production-ready Tabs component step by step, and by the end you'll recognize the same skeleton in every library component you use.

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>

✨ Cleaner idiom: Object.assign

The as SelectComponent cast works, but you have to hand-write the intersection type and keep it in sync with what you attach. Object.assign lets the compiler infer the attached-component types for you β€” one source of truth, no cast, no drift:

// Name the root function, then attach children in one expression.
function SelectRoot({ children }: { children: ReactNode }) {
    return <div>{children}</div>;
}

// TypeScript infers the full type of the combined object.
export const Select = Object.assign(SelectRoot, {
    Trigger: SelectTrigger,
    Option: SelectOption,
});

// Select.Trigger and Select.Option are fully typed β€” no manual intersection,
// no `as`, and adding a new sub-component updates the type automatically.

Both approaches produce the same ergonomic <Select.Option> API; Object.assign just removes the busywork and the risk of the cast silently going stale.

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 at the type level: annotate with the child's PROPS type.
// (ReactElement's first type argument is the props, so use TabProps β€”
//  ReactElement<typeof Tab> would be wrong: typeof Tab is the component, not its props.)
function StrictTabList({ children }: {
    children: ReactElement<TabProps> | ReactElement<TabProps>[]
}) {
    return <div role="tablist">{children}</div>;
}

⚠️ Use child-type validation sparingly

Checking child.type === Tab and typing children as "only Tab elements" feels tidy, but it's brittle: it breaks when a tab is wrapped in a <div>, produced by .map(), or swapped for a memoized version. TypeScript also can't truly forbid other children β€” the annotation is a hint, not a hard guarantee. Prefer letting Context do the coordinating (as in our Tabs and Accordion), and reserve child inspection for cases where you genuinely must, like collecting named slots.

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, ComponentRef, Ref } 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 (element instance) type. Prefer ComponentRef in React 19+ β€”
// ElementRef still works but is deprecated in the latest @types/react.
type ButtonRef = ComponentRef<'button'>; // HTMLButtonElement

// React 19: ref is just a regular prop β€” no forwardRef needed.
type ForwardedButtonProps = ButtonProps & { ref?: Ref<ButtonRef> };

function ForwardedButton({ variant = 'primary', ref, ...props }: ForwardedButtonProps) {
    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, useState, useCallback, Ref } 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;
    // React 19: ref is a normal prop β€” no forwardRef wrapper required.
    ref?: Ref<TabsHandle>;
}

function Tabs({ children, defaultValue, ref }: TabsProps) {
    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>
    );
}

πŸ’‘ React 19 changed the ref story

If you learned React before 2024, you'll remember wrapping components in forwardRef to accept a ref. As of React 19, function components receive ref as an ordinary prop, so forwardRef is no longer needed for new code (it still works, and is only deprecated, not removed). That's why the example above is a plain function that lists ref in its props β€” cleaner to write and to type. Reach for an imperative handle sparingly, though: prefer declarative state and the controlled/uncontrolled props you built earlier, and expose imperative methods only when a parent genuinely needs to command the component (focus it, advance it, reset it).

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

  1. Make it obvious: API should be self-explanatory
  2. Minimize required props: Sensible defaults for everything
  3. Fail fast with helpful errors: Don't let bugs hide
  4. Be consistent: Similar components should work similarly
  5. Optimize for the common case: Simple things should be simple
  6. Provide escape hatches: Complex things should be possible
  7. 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

πŸš€ 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! 🌟