The Compound Component Pattern in React

When building complex UIs in React, you often encounter situations where a parent component needs to manage the state, while its children (subcomponents) need to communicate with each other and the parent in a seamless, flexible way. A great solution fo this is the Compound Component Pattern.

In this article, we will dive deep into what the compound component pattern is, how it works. Why it is beneficial, and how to implement it in React using the Context API. By the end, you will have a solid understanding of how to use this pattern effectively in your own applications.

But first, let’s explore a case with traditional prop-passing patterns and how the compound pattern helps solve these issues.

Imagine you are building a Dropdown component traditional way, passing state and handlers down through props. Here is how it might look:

interface DropdownProps {
  isOpen: boolean;
  onToggle: () => void;
}

const Dropdown: React.FC<DropdownProps> = ({ isOpen, onToggle, children }) => {
  return (
    <div>
      <button onClick={onToggle}>Toggle Dropdown</button>
      {isOpen && <ul>{children}</ul>}
    </div>
  );
};

const App: React.FC = () => {
  const [open, setOpen] = useState(false);

  const toggleDropdown = () => setOpen(!open);

  return (
    <Dropdown isOpen={open} onToggle={toggleDropdown}>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </Dropdown>
  );
};

This approach works for a simple dropdown component. But there are a few key problems:

  1. Prop drilling: This isOpen and onToggle state have to passed down from the parent Dropdown component and if you have multiple nested components, this become cumbersome. For example, if you wanted to move the toggle logic into a Dropdown.Toggle component and have a Dropdown.Menu that shows the list, you would need to keep passing the isOpen and onToggle props down though every components.
  2. Tight Coupling: The Dropdown component is tightly coupled to its children. Any new functionality or behavior change (e.g, adding a close button inside Dropdown.Menu) will require changes in the App component to pass down more props.
  3. Limited Flexibility: This pattern – prop-passing pattern, makes it difficult to reuse or rearrange the components. Each new child component needs to accept isOpen and onToggle as props, which can make the API unwieldy over time.

Let’s see what happens if we decide to split the toggle and menu logic into separate components:

const DropdownToggle: React.FC<{ onToggle: () => void }> = ({ onToggle }) => {
  return <button onClick={onToggle}>Toggle Dropdown</button>;
};

const DropdownMenu: React.FC<{ isOpen: boolean }> = ({ isOpen, children }) => {
  return isOpen ? <ul>{children}</ul> : null;
};

const App: React.FC = () => {
  const [open, setOpen] = useState(false);

  const toggleDropdown = () => setOpen(!open);

  return (
    <>
      <DropdownToggle onToggle={toggleDropdown} />
      <DropdownMenu isOpen={open}>
        <li>Item 1</li>
        <li>Item 2</li>
        <li>Item 3</li>
      </DropdownMenu>
    </>
  );
};

Now, we have split the DropdownToogle and DropdownMenu into separate components, but the problem persists:

  • We still need manually pass onToggle to DropdownToggle and isOpen to DropdownMenu. If you want to extend this further (e.g., adding nested components), the number of props pass down increases, making the code harder to manage.

So now, the solution Compound Pattern comes and solves the problem of Prop-passing patterns.

Why is the Compound Component Pattern?

The Compound Component Pattern is React design pattern lets you create a group of related components that work together as a single cohensive unit, but are still independently reusable. These subcomponents share state and behavior from parent component, which manages their interactions behind the scenes.

In simpler terms, instead of passing down many props and cluttering your code, you can create a set of related components (e.g, Dropdown.Toggle, Dropdown.Menu, or Dropdown.Item) that automatically know how to work together by accessing shared through React’s Context API.

Why use the Compound Component Pattern?

  1. Better reusability: Subcomponents can be reused across different places with your application.
  2. Cleaner code: You avoid prop drilling, making your code cleaner and more maintainable. Instead of passing multiple props down several layers, components access shared logic via context.
  3. Improved Flexibility: You can compose subcomponents in different ways to create various UI structures, all while using the same core components.
  4. Separation of Concerns: The parents component manages the state, while child components focus on presentation and behavior.

Understanding the Context API

Before jumping into the code, it is important to understand how the Context API works in React. Context provides a way to pass data though the component tree without having to pass props manually at every level. It allows us to share state of functions globally within a section fo the component tree.

The Compound Component Pattern often leverages context to share the state between the parent and the child components.

The Compound Component Pattern in Action

Let’s walk though an example by building a Dropdown component with there subcomponents: Dropdown.Toggle , Dropdown.Menu, Dropdown.Item. The Dropdown component will manage the state, and the child components will access that state though context to build the UI.

Here is what we want to archive:

<Dropdown>
  <Dropdown.Toggle />
  <Dropdown.Menu>
    <Dropdown.Item>Item 1</Dropdown.Item>
    <Dropdown.Item>Item 2</Dropdown.Item>
    <Dropdown.Item>Item 3</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>

Step 1: Create the context

We will create a context the will store the state (open) and a method to toggle it (toggleDropdown).

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

interface DropdownContextType {
  open: boolean;
  toggleDropdown: () => void;
}

const DropdownContext = createContext<DropdownContextType | undefined>(undefined);

The DropdownContext will hold the state and a function to toggle that state.

Step 2: Create the Dropdown Component

The Dropdown component will manage the open state and provide the toggleDropdown function. This state will be shared with child components using DropdownContext.Provider.

interface DropdownProps {
  children: ReactNode;
}

const Dropdown: React.FC<DropdownProps> = ({ children }) => {
  const [ open, setOpen] = useState(false);

  const toggleDropdown = () => setOpen(!open);

  return (
    <DropdownContext.Provider value={{ open, toggleDropdown }}>
      {children}
    </DropdownContext.Provider>
  );
};

This component uses the useState hook to manage the dropdown’s visibility (open), and it provides that state and the toggleDropdown function to its children via the DropdownContext.Provier.

Step 3: Create the Menu and Item Components

The Menu component displays the dropdown items only if the dropdown is open and Item renders each individual list item.

interface MenuProps {
  children: ReactNode;
}

const Menu: React.FC<MenuProps> = ({ children }) => {
  const context = useContext(DropdownContext);

  if (!context) {
    throw new Error("Menu must be used within a Dropdown");
  }

  const { open } = context;

  return open ? <ul>{children}</ul> : null;
};

const Item: React.FC<{ children: ReactNode }> = ({ children }) => {
  return <li>{children}</li>;
};

The Menu component checks whether the dropdown is open before rendering the list (ul), while the Item component simply renders its children inside a list item (li).

Step 5: Attach Subcomponent to the Parent.

We can now attach the child components (Toggle, Menu, Item) to Dropdown Component for a neat API.

Dropdown.Toggle = Toggle;
Dropdown.Menu = Menu;
Dropdown.Item = Item;

export default Dropdown;

By attaching subcomponents to the Dropdown, we create a natural and native API for users of this component.

Step 6: Using the Dropdown Component

Here is how we can now use the Dropdown component in our application:

const App: React.FC = () => {
  return (
    <Dropdown>
      <Dropdown.Toggle />
      <Dropdown.Menu>
        <Dropdown.Item>Item 1</Dropdown.Item>
        <Dropdown.Item>Item 2</Dropdown.Item>
        <Dropdown.Item>Item 3</Dropdown.Item>
      </Dropdown.Menu>
    </Dropdown>
  );
};

With this simple structure, the Dropdown component now has a clean, composable API where the child components (Toggle, Menu, Item) can be mixed and matched as needed.

Benefits of the Compound Component Pattern

  1. Decoupling State and Presentation: The Dropdown component handles all the logic and state, while its children focus solely on rendering. This separation keeps your code clean and easy to maintain.
  2. Flexibility in Composition: By composing the child components together in different ways, you can build a variety of different dropdown structures without modifying the internal logic of the components.
  3. Contextual Awareness: Components like Toggle and Menu automatically “know” about the state of the dropdown by accessing shared state though the context. You do not need to pass props manually between them.
  4. Scalability: The pattern scales well for more complex UI component (e.g., Tabs. Accordions, Forms) because it keeps state and logic localized in the parent while allowing each child component to interact with that state easily.

The problem

But there have a drawback of Compound Component Pattern. The problem is the subcomponents (Dropdown.Toggle, Dropdown.Meu. Dropdown.Item) depend on the DropdownContext that provided by the Dropdown component. This context contains the shared state and the function to toggle it. When the subcomponents are placed inside the parent (Dropdown). They can access this context via useContext. However, if the subcomponents are used outside the dropdown, the context will be undefined, leading to errors or unexpected behavior.

For example:

<Dropdown.Menu>
  <Dropdown.Item>Item 1</Dropdown.Item>
  <Dropdown.Item>Item 2</Dropdown.Item>
</Dropdown.Menu>

This works fine within the Dropdown because the DropdownContext is available. But if we attempt to use Dropdown.Menu outside the Dropdown:

...
<div>
    <Dropdown.Menu>
      <Dropdown.Item>Item 1</Dropdown.Item>
    </Dropdown.Menu>
</div>
...

Here, the Menu component won’t have access to the DropdownContext, so it won’t know whether the dropdown is open or closed, and it won’t work as expected.

https://codesandbox.io/p/sandbox/lrt9f4

How to Handle This Problem

There are a few ways to handle this issue and provide better developer experience while maintaining the integrity of the compound pattern.

1. Throwing an Error if Context is Missing

As seen in the example I provided earlier, you can explicitly check if the context is available inside the subcomponents and throw an error if it’s not. This is a way to enforce correct usage and notify the developer that the component is being used incorrectly.

const Menu: React.FC<MenuProps> = ({ children }) => {
  const context = useContext(DropdownContext);

  if (!context) {
    throw new Error("Menu must be used within a Dropdown");
  }

  const { open } = context;
  return open ? <ul>{children}</ul> : null;
};

This approach provides a clear and early error message, helping the developer quickly identify that the subcomponent must be used inside the parent.

2. Documenting the Pattern

In practice, it’s a good idea to document the expected usage of the compound components clearly. For example, when you create a Dropdown component, make sure to document that Toggle, Menu, and Item are designed to work within Dropdown, and that using them outside the context won’t work as expected.

Conclusion

The Compound Component Pattern or Compound Pattern is a powerful and flexible way to build UI components in React. By leveraging the Context API, you can manage shared state and behavior between multiple subcomponents while keeping them decoupled and reusable. This approach leads to cleaner, more maintain code, especially in larger and more complex projects.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top