Skip to content
Codeloom
React

React Compound Components: Flexible APIs Without Prop Drilling

Build flexible, composable React components using the compound components pattern with Context, implicit state sharing, and controlled inversion.

·6 min read · By Codeloom
Advanced 11 min read

What you'll learn

  • What compound components are and what problem they solve
  • How to implement them with React Context
  • Patterns for controlled and uncontrolled compound components
  • How to validate children and provide useful error messages
  • When compound components are the right choice

Prerequisites

None — this post is self-contained.

A compound component is a set of components that work together to form a complete UI element, sharing implicit state through Context rather than explicit props. Think of <select> and <option> in HTML. The <select> manages the state. The <option> elements define the choices. They communicate implicitly through the DOM. Compound components bring this pattern to React.

The problem compound components solve

Consider building a tabs component. The naive approach uses a single component with a complex configuration prop.

// Inflexible: layout and content are locked together
<Tabs
  tabs={[
    { label: 'Profile', content: <ProfilePanel /> },
    { label: 'Settings', content: <SettingsPanel /> },
    { label: 'Billing', content: <BillingPanel /> },
  ]}
/>

This works until the consumer wants to customize the tab button styling, add an icon to one tab, put a badge on another, or rearrange the layout. Every customization requires a new prop, and the API surface grows uncontrollably.

Compound components solve this by splitting the API into composable pieces:

<Tabs defaultValue="profile">
  <TabList>
    <Tab value="profile">Profile</Tab>
    <Tab value="settings">
      <SettingsIcon /> Settings
    </Tab>
    <Tab value="billing">
      Billing <Badge count={3} />
    </Tab>
  </TabList>
  <TabPanels>
    <TabPanel value="profile"><ProfilePanel /></TabPanel>
    <TabPanel value="settings"><SettingsPanel /></TabPanel>
    <TabPanel value="billing"><BillingPanel /></TabPanel>
  </TabPanels>
</Tabs>

Each piece is a standard React component. The consumer controls layout, styling, and content. The compound component manages the state and coordination behind the scenes.

Implementation with Context

The parent component creates a Context that holds the shared state. Child components consume that Context to read and update the state.

import { createContext, useContext, useState, type ReactNode } from 'react';

// 1. Define the shared state shape
type TabsContextType = {
  activeTab: string;
  setActiveTab: (value: string) => void;
};

const TabsContext = createContext<TabsContextType | null>(null);

function useTabsContext() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs compound components must be used within a <Tabs> provider');
  }
  return context;
}

// 2. Parent component provides state
export function Tabs({
  defaultValue,
  children,
}: {
  defaultValue: string;
  children: ReactNode;
}) {
  const [activeTab, setActiveTab] = useState(defaultValue);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

// 3. Child components consume state
export function TabList({ children }: { children: ReactNode }) {
  return <div role="tablist" className="tab-list">{children}</div>;
}

export function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { activeTab, setActiveTab } = useTabsContext();

  return (
    <button
      role="tab"
      aria-selected={activeTab === value}
      className={activeTab === value ? 'tab active' : 'tab'}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

export function TabPanels({ children }: { children: ReactNode }) {
  return <div className="tab-panels">{children}</div>;
}

export function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { activeTab } = useTabsContext();

  if (activeTab !== value) return null;

  return (
    <div role="tabpanel" className="tab-panel">
      {children}
    </div>
  );
}

The Tab component reads and writes the active tab. The TabPanel component reads it to decide whether to render. Neither component receives the active tab as a prop. The Context handles the plumbing.

Building an Accordion

Here is a second example to reinforce the pattern. An accordion manages which sections are expanded.

import { createContext, useContext, useState, type ReactNode } from 'react';

type AccordionContextType = {
  expandedItems: Set<string>;
  toggle: (id: string) => void;
};

const AccordionContext = createContext<AccordionContextType | null>(null);

function useAccordionContext() {
  const ctx = useContext(AccordionContext);
  if (!ctx) throw new Error('Accordion components must be used within <Accordion>');
  return ctx;
}

export function Accordion({
  multiple = false,
  children,
}: {
  multiple?: boolean;
  children: ReactNode;
}) {
  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());

  function toggle(id: string) {
    setExpandedItems((prev) => {
      const next = new Set(multiple ? prev : []);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }

  return (
    <AccordionContext.Provider value={{ expandedItems, toggle }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

export function AccordionItem({ id, children }: { id: string; children: ReactNode }) {
  const { expandedItems } = useAccordionContext();
  const isExpanded = expandedItems.has(id);

  return (
    <div className="accordion-item" data-expanded={isExpanded || undefined}>
      {children}
    </div>
  );
}

export function AccordionTrigger({ id, children }: { id: string; children: ReactNode }) {
  const { expandedItems, toggle } = useAccordionContext();

  return (
    <button
      className="accordion-trigger"
      aria-expanded={expandedItems.has(id)}
      onClick={() => toggle(id)}
    >
      {children}
    </button>
  );
}

export function AccordionContent({ id, children }: { id: string; children: ReactNode }) {
  const { expandedItems } = useAccordionContext();

  if (!expandedItems.has(id)) return null;

  return <div className="accordion-content">{children}</div>;
}

Usage is clean and composable:

<Accordion multiple>
  <AccordionItem id="faq-1">
    <AccordionTrigger id="faq-1">What is your return policy?</AccordionTrigger>
    <AccordionContent id="faq-1">
      <p>You can return items within 30 days of purchase.</p>
    </AccordionContent>
  </AccordionItem>
  <AccordionItem id="faq-2">
    <AccordionTrigger id="faq-2">Do you ship internationally?</AccordionTrigger>
    <AccordionContent id="faq-2">
      <p>Yes, we ship to over 50 countries.</p>
    </AccordionContent>
  </AccordionItem>
</Accordion>

Controlled compound components

Sometimes the parent needs to control the compound component from the outside. Support both controlled and uncontrolled modes:

export function Tabs({
  value,
  defaultValue,
  onChange,
  children,
}: {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  children: ReactNode;
}) {
  const [internalValue, setInternalValue] = useState(defaultValue ?? '');
  const isControlled = value !== undefined;
  const activeTab = isControlled ? value : internalValue;

  function setActiveTab(newValue: string) {
    if (!isControlled) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  }

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

When value is provided, the component is controlled. The parent owns the state and passes it down. When value is omitted, the component manages its own state internally.

Error messages for misuse

Compound components fail silently when used outside their provider. The useTabsContext hook above throws an error, but you can add more specific guidance:

function useTabsContext() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error(
      '<Tab> must be rendered inside a <Tabs> component. ' +
      'Example:\n' +
      '  <Tabs defaultValue="a">\n' +
      '    <TabList>\n' +
      '      <Tab value="a">Tab A</Tab>\n' +
      '    </TabList>\n' +
      '  </Tabs>'
    );
  }
  return context;
}

Good error messages save time for everyone using the component.

When to use compound components

Compound components shine when the component has multiple child elements that share state, when consumers need control over layout and rendering, and when the configuration-object approach leads to a bloated props API.

They are overkill for simple components with few variations. A <Button> does not need to be a compound component. A <Dialog> with a title, body, and footer probably does.

The pattern works best when the child components have a clear parent-child relationship and the shared state is simple (active tab, expanded items, selected value). For complex shared state with many operations, consider whether a custom hook with explicit props would be clearer than implicit Context.