React Design Pattern: Compound Components
AI generated
</>
{ }
React · Design Patterns · Composition · TypeScript
React Design Pattern:
Compound Components

Prop drilling and monolithic components with dozens of props are a reliable sign of an architecture that will not scale. The Compound Components pattern in React solves this problem through explicit composition: sub-components implicitly share state via context, the API stays self-documenting, and it remains maximally flexible.

12 min read Compound Components · Context · Composition · Sub-components React 18+ · TypeScript · Tailwind CSS

1. The problem with monolithic props components

The starting point for the Compound Components pattern is a problem that shows up in nearly every React project after a few weeks: a component keeps growing more and more props. A Select component starts out with options and value. Then disabled, placeholder, isSearchable, renderOption, renderTrigger, onOpen, onClose, and maxHeight get added. Soon the component has twenty props, of which only five are used in any given case. The documentation grows, the code becomes hard to read, and testing turns into a chore.

The actual problem is that a monolithic component tries to control every possible variation through props. That violates the Open/Closed Principle: the component has to be opened up and changed for every new requirement. The Compound Components pattern solves this by inverting the approach: instead of one component with many props, there are several specialized sub-components combined through composition. Each sub-component does exactly one thing, and the consumer decides how they are assembled.

A classic example from the React world is the browser's <select> element: it does not accept an options prop as an array, but sub-elements: <option> and <optgroup>. That is Compound Components as an HTML concept. The same pattern can be implemented in React for custom component libraries, and popular libraries like Headless UI, Radix UI, and Reach UI are built exactly on top of it.

2. The Compound Components concept explained

The Compound Components pattern consists of two parts: a parent component that holds the shared state and passes it down via React Context, and sub-components that consume this context without props having to be explicitly passed down. The parent component and its sub-components together form a logical unit, a compound component.

The consumer of the component writes explicit, self-documenting JSX. Instead of <Tabs activeTab="details" tabs={[...]} onTabChange={...}/>, they write <Tabs><Tabs.List><Tabs.Tab id="details">Details</Tabs.Tab></Tabs.List><Tabs.Panel id="details">...</Tabs.Panel></Tabs>. That is longer, but self-documenting, flexible, and extensible without changes to the component. Sub-components can be placed at different points in the JSX tree, the internal context communication works independently of the DOM structure.


// accordion.tsx, Minimal Compound Components implementation
import { createContext, useContext, useState, type ReactNode } from 'react';

// Internal context, not exported, consumers can't access it directly
interface AccordionContextValue {
  openItem: string | null;
  toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);

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

// Root component, holds shared state and provides context
function Accordion({ children, defaultOpen }: { children: ReactNode; defaultOpen?: string }) {
  const [openItem, setOpenItem] = useState<string | null>(defaultOpen ?? null);
  const toggle = (id: string) => setOpenItem(prev => (prev === id ? null : id));

  return (
    <AccordionContext.Provider value={{ openItem, toggle }}>
      <div className="divide-y divide-slate-200 rounded-2xl border border-slate-200 overflow-hidden">
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

// Sub-components, consume context without prop drilling
Accordion.Item = function AccordionItem({ id, title, children }: { id: string; title: string; children: ReactNode }) {
  const { openItem, toggle } = useAccordionContext();
  const isOpen = openItem === id;

  return (
    <div>
      <button
        onClick={() => toggle(id)}
        aria-expanded={isOpen}
        aria-controls={`panel-${id}`}
        className="w-full flex justify-between items-center px-6 py-4 font-semibold text-slate-800 hover:bg-slate-50"
      >
        {title}
        <span className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}>▾</span>
      </button>
      {isOpen && (
        <div id={`panel-${id}`} role="region" className="px-6 py-4 bg-slate-50 text-slate-700 text-sm">
          {children}
        </div>
      )}
    </div>
  );
};

// Usage, self-documenting, no prop arrays, fully composable
export function FaqSection() {
  return (
    <Accordion defaultOpen="shipping">
      <Accordion.Item id="shipping" title="How long does shipping take?">
        Standard delivery 3-5 business days, express 1-2 business days.
      </Accordion.Item>
      <Accordion.Item id="returns" title="How do returns work?">
        Free of charge within 30 days.
      </Accordion.Item>
    </Accordion>
  );
}

3. Implementation with React Context

The core of every Compound Components pattern is an internal React Context that communicates between the parent component and the sub-components. This context is deliberately not exported, it is an implementation detail of the compound component, not a public API. Consumers of the compound component only see the component class and its sub-components, not the internal context.

The custom hook useXxxContext() wraps the useContext call and adds an invariant check: if a sub-component is used outside its parent component, the hook throws a clear error with a helpful error message. That significantly improves the developer experience compared to a cryptic Cannot read properties of null error. The parent component provides the context via a Provider and holds the shared state with useState or useReducer.

4. Practical example: custom select dropdown

A select dropdown is a perfect example for Compound Components because it has a complex internal state (open/closed, focused option, selected value) and at the same time must offer maximum flexibility in how the options are displayed. With a props array such as options={[...]} you cannot implement complex option layouts with icons, descriptions, or sub-groups. With Compound Components that is trivial.

The pattern allows the options to be structured and extended in any way without changing the select component. New option types (separators, headings, groups) simply arise from new sub-components. The consumer of the select component has complete control over the layout of each option, while the core logic (keyboard navigation, ARIA, opening and closing) stays in the parent component.


// select.tsx, Compound Components Select with keyboard navigation
import { createContext, useContext, useState, useRef, useCallback, type ReactNode } from 'react';

interface SelectContextValue {
  isOpen: boolean;
  selectedValue: string | null;
  open: () => void;
  close: () => void;
  select: (value: string, label: string) => void;
  selectedLabel: string | null;
}

const SelectContext = createContext<SelectContextValue | null>(null);
const useSelectContext = () => {
  const ctx = useContext(SelectContext);
  if (!ctx) throw new Error('Select sub-components must be inside <Select>');
  return ctx;
};

interface SelectProps {
  value?: string | null;
  onChange?: (value: string) => void;
  children: ReactNode;
}

function Select({ value: controlledValue, onChange, children }: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [internalValue, setInternalValue] = useState<string | null>(null);
  const [selectedLabel, setSelectedLabel] = useState<string | null>(null);

  const selectedValue = controlledValue !== undefined ? controlledValue : internalValue;

  const select = useCallback((value: string, label: string) => {
    setInternalValue(value);
    setSelectedLabel(label);
    setIsOpen(false);
    onChange?.(value);
  }, [onChange]);

  return (
    <SelectContext.Provider value={{
      isOpen, selectedValue, selectedLabel,
      open: () => setIsOpen(true),
      close: () => setIsOpen(false),
      select,
    }}>
      <div className="relative">{children}</div>
    </SelectContext.Provider>
  );
}

// Trigger sub-component, renders the visible button
Select.Trigger = function SelectTrigger({ placeholder = 'Please choose ...' }: { placeholder?: string }) {
  const { isOpen, open, close, selectedLabel } = useSelectContext();
  return (
    <button
      type="button"
      onClick={() => isOpen ? close() : open()}
      aria-haspopup="listbox"
      aria-expanded={isOpen}
      className="w-full flex justify-between items-center px-4 py-2 border border-slate-300 rounded-lg bg-white text-sm font-medium text-slate-700 hover:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
    >
      <span className={selectedLabel ? 'text-slate-800' : 'text-slate-400'}>
        {selectedLabel ?? placeholder}
      </span>
      <span className={`transition-transform text-slate-400 ${isOpen ? 'rotate-180' : ''}`}>▾</span>
    </button>
  );
};

// Options container
Select.Options = function SelectOptions({ children }: { children: ReactNode }) {
  const { isOpen } = useSelectContext();
  if (!isOpen) return null;
  return (
    <ul role="listbox" className="absolute z-50 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg py-1 max-h-60 overflow-auto">
      {children}
    </ul>
  );
};

// Individual option
Select.Option = function SelectOption({ value, children }: { value: string; children: ReactNode }) {
  const { selectedValue, select } = useSelectContext();
  const isSelected = selectedValue === value;
  return (
    <li
      role="option"
      aria-selected={isSelected}
      onClick={() => select(value, typeof children === 'string' ? children : value)}
      className={`px-4 py-2 text-sm cursor-pointer flex items-center gap-2 ${isSelected ? 'bg-sky-50 text-sky-700 font-semibold' : 'text-slate-700 hover:bg-slate-50'}`}
    >
      {isSelected && <span>✓</span>}
      {children}
    </li>
  );
};

5. Practical example: tabs component

A tabs component is one of the most common UI patterns in web applications and illustrates especially well why the Compound Components pattern is superior to props-based approaches. A props-based tabs component typically expects an array of tab configuration objects: tabs={[{id, label, content}]}. That works for simple cases, but as soon as tabs need different layouts, icons, badges, disabled states, custom content, the configuration explodes.

With Compound Components the solution is clean: Tabs.List contains Tabs.Tab elements that can render arbitrary content. Tabs.Panel holds the content of the respective tab. The connection between tab and panel is made via a shared id. The developer can build tabs with icons, badges, long labels, or custom layouts without changing the tabs component itself. That is the Open/Closed Principle in practice.

6. Typing and TypeScript best practices

TypeScript and Compound Components work together very well, but they require a bit more thought than simple props types. The context type is defined as an interface and passed as a generic parameter to the createContext call. The null initial value forces the null check in the custom hook, which then throws a precise error. This null check is not a cosmetic touch, it is a safety net that protects developers from incomprehensible runtime errors.

Sub-components are typically defined as direct properties of the parent component: Accordion.Item = function() {...}. That works in TypeScript with one small addition: the type of the parent component must be explicitly extended with the sub-components. An elegant way is to define the sub-components first and then do the assignment: const Select = Object.assign(SelectRoot, { Trigger: SelectTrigger, Options: SelectOptions, Option: SelectOption }). That gives TypeScript all the information it needs for correct autocompletion.


// dialog.tsx, Typed Compound Component with Object.assign pattern
import { createContext, useContext, useId, useState, type ReactNode } from 'react';

// Context type, exported for external extension if needed
export interface DialogContextValue {
  isOpen: boolean;
  dialogId: string;
  titleId: string;
  descriptionId: string;
  open: () => void;
  close: () => void;
}

const DialogContext = createContext<DialogContextValue | null>(null);

export function useDialogContext(): DialogContextValue {
  const ctx = useContext(DialogContext);
  if (!ctx) throw new Error('Dialog sub-components must be used inside <Dialog>');
  return ctx;
}

// Root component using function keyword for clear stack traces
function DialogRoot({ children, defaultOpen = false }: { children: ReactNode; defaultOpen?: boolean }) {
  const [isOpen, setIsOpen] = useState(defaultOpen);
  const id = useId(); // React 18, generates stable, unique ID

  return (
    <DialogContext.Provider value={{
      isOpen,
      dialogId: `dialog-${id}`,
      titleId: `dialog-title-${id}`,
      descriptionId: `dialog-desc-${id}`,
      open: () => setIsOpen(true),
      close: () => setIsOpen(false),
    }}>
      {children}
    </DialogContext.Provider>
  );
}

// Sub-components with explicit typing
const DialogTrigger = ({ children }: { children: ReactNode }) => {
  const { open } = useDialogContext();
  return <button onClick={open}>{children}</button>;
};

const DialogPanel = ({ children }: { children: ReactNode }) => {
  const { isOpen, dialogId, titleId, descriptionId, close } = useDialogContext();
  if (!isOpen) return null;
  return (
    <div role="dialog" id={dialogId} aria-labelledby={titleId} aria-describedby={descriptionId} aria-modal="true">
      <div className="fixed inset-0 bg-black/50 z-40" onClick={close} />
      <div className="fixed inset-0 flex items-center justify-center z-50 p-4">
        <div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full p-6">{children}</div>
      </div>
    </div>
  );
};

const DialogTitle = ({ children }: { children: ReactNode }) => {
  const { titleId } = useDialogContext();
  return <h2 id={titleId} className="text-xl font-bold text-slate-800 mb-2">{children}</h2>;
};

const DialogDescription = ({ children }: { children: ReactNode }) => {
  const { descriptionId } = useDialogContext();
  return <p id={descriptionId} className="text-slate-600 text-sm">{children}</p>;
};

// Object.assign pattern, TypeScript sees all sub-components
export const Dialog = Object.assign(DialogRoot, {
  Trigger: DialogTrigger,
  Panel: DialogPanel,
  Title: DialogTitle,
  Description: DialogDescription,
});

7. Accessibility (ARIA) in Compound Components

Compound Components are ideal for accessible UI patterns because the ARIA attributes and the DOM structure are tightly linked to the state of the parent component. The context holds not only the visual state but also the IDs needed for ARIA relationships between elements. useId from React 18 generates stable, unique IDs that also work correctly with server-side rendering.

In a tabs pattern, the parent component holds the ID of the active tab, and the Tabs.Tab sub-component sets aria-selected based on a comparison with the active tab from the context. The Tabs.Panel component sets aria-labelledby to the ID of the associated tab. These relationships arise automatically through the shared context, without the consumer of the component having to set a single ARIA attribute.

8. Testing strategy for Compound Components

Compound Components can be tested very well with React Testing Library because they render semantic HTML that can be addressed directly through ARIA roles and labels. An accordion test opens an item by clicking the button, which is addressed by an accessible name, and then checks whether the panel content is visible. That corresponds exactly to the behavior a keyboard user or screen reader user experiences.

The parent component can also be tested in isolation by assembling only certain sub-components in a test wrapper. That allows targeted tests for individual state transitions without the overhead of the full component. Snapshot tests are less valuable for Compound Components than interaction tests, because the visual output depends heavily on the composition chosen by the consumer.

9. Compound Components vs. other patterns

The Compound Components pattern is one of several composition patterns in React. It is worth weighing it against the alternatives in order to choose the right tool for each situation.

Pattern Strength Weakness Typical use
Compound Components Maximum flexibility, self-documenting More code than a props API Tabs, accordion, select, dialog
Props API Little code, easy to document Props explosion with many variations Simple buttons, icons, labels
Render Props Maximum render control Callback hell, hard to read Mostly replaced by custom hooks today
Custom Hook Logic extraction without UI coupling No implicit state sharing between components Forms, API calls, event handlers
HOC Cross-cutting concerns (auth, logging) Wrapper hell, props conflicts Rare in modern React

In practice, these patterns are combined. A tabs component is built on Compound Components for its structure, uses custom hooks internally for keyboard navigation, and offers a props API to the outside for simple use cases as an alternative to full composition. That gives consumers a choice: a simple API for simple cases, full composition for complex layouts.