Back to Blog
Lesson 15 of the Advanced React: Performance, Architecture & Patterns course
ReactJune 27, 20263 min read

Designing Compound Components: Advanced React Architecture Patterns

Master Compound Components in React to build flexible, intuitive UI APIs. Learn to share implicit state and enforce structure without the mess of prop drilling.

ReactPatternsArchitectureComponent DesignCompound Componentsjavascriptfrontend

Previously in this course, we explored how to eliminate prop drilling by leveraging composition and context. While that approach solves the data-flow problem, it doesn't always provide the cleanest developer experience for the consumer of your components.

When building complex UI primitives—like Tabs, Modals, or Select menus—you often end up with "Prop Hell," where the parent component is bloated with dozens of configuration props. Compound Components solve this by allowing the parent to coordinate state implicitly, while the user assembles the component structure themselves.

The Compound Component Pattern from First Principles

At its core, a Compound Component is a group of components that work together to form a single functional unit. The "Parent" component acts as a state container, and the "Children" act as the interface, communicating with that parent through a shared React Context.

Unlike standard components where you pass an object of settings, Compound Components rely on Inversion of Control. You provide the primitives, and the consumer decides where they go in the DOM tree. This is a foundational step toward building a Headless UI library.

Worked Example: A Flexible Toggle Switch

Let's build a Toggle component. Instead of passing onToggle, isOn, and label to a single component, we want the API to look like this:

JSX
<Toggle>
  <Toggle.On>The switch is on!</Toggle.On>
  <Toggle.Off>The switch is off!</Toggle.Off>
  <Toggle.Button />
</Toggle>

Step 1: The State Provider

We need a context to share the on state and the toggle function.

JSX
const ToggleContext = React.createContext();

function Toggle({ children }) {
  const [on, setOn] = React.useState(false);
  const toggle = () => setOn(!on);

  // Memoize the value to avoid unnecessary re-renders
  const value = React.useMemo(() => ({ on, toggle }), [on]);

  return (
    <ToggleContext.Provider value={value}>
      {children}
    </ToggleContext.Provider>
  );
}

Step 2: Exposing Sub-Components

We attach the sub-components to the main Toggle function object. This makes the API discoverable (e.g., Toggle.Button).

JSX
function ToggleOn({ children }) {
  const { on } = React.useContext(ToggleContext);
  return on ? children : null;
}

function ToggleOff({ children }) {
  const { on } = React.useContext(ToggleContext);
  return !on ? children : null;
}

function ToggleButton() {
  const { on, toggle } = React.useContext(ToggleContext);
  return <button onClick={toggle}>{on ? CE9178">'ON' : CE9178">'OFF'}</button>;
}

// Attach sub-components
Toggle.On = ToggleOn;
Toggle.Off = ToggleOff;
Toggle.Button = ToggleButton;

Enforcing Structural Requirements

Sometimes, you need to ensure a component is used correctly. If a sub-component is used outside of its parent, it will throw an error because the context will be undefined.

Always create a custom hook to consume the context and provide a helpful error message:

JSX
function useToggleContext() {
  const context = React.useContext(ToggleContext);
  if (!context) {
    throw new Error(CE9178">'Toggle sub-components must be used within <Toggle>');
  }
  return context;
}

Hands-on Exercise

Refactor an existing "Accordion" component in your project. Currently, it likely takes an array of objects items={[{title, content}]}.

  1. Convert it to use the Compound Component pattern: <Accordion><Accordion.Item><Accordion.Header /><Accordion.Panel /></Accordion.Item></Accordion>.
  2. Use a context to manage which item is currently expanded.
  3. Ensure that Accordion.Header throws a meaningful error if rendered outside of an Accordion.Item.

Common Pitfalls

  • Over-nesting: Don't turn every component into a compound component. If a component is simple, a standard props interface is more readable.
  • Performance: If your parent component updates state frequently, ensure the value passed to the Provider is memoized with useMemo, as we did in the example above.
  • Static Property Bloat: Attaching too many sub-components to the main component can make the main component's file difficult to navigate. Consider moving sub-components into a components/ directory if the list grows beyond 3-4 items.

Recap

Compound Components provide an elegant way to handle complex UI state. By using React Context to implicitly pass state, you empower the consumer to control the markup structure while keeping your logic encapsulated and reusable. This pattern is essential for creating Design System Primitives that feel like native HTML elements.

Up next: We will look at the Render Props Pattern, which takes this concept of inversion of control even further by allowing components to share logic without dictating the UI implementation.

Similar Posts