© 2024 Jeremiah Yee

Building React Reusable Components Part II: Composing and Compounding Components

24 Nov 2024

Introduction

Previously, I wrote about how to differentiate and write controlled and uncontrolled components. In this installment, we’ll explore how composition and the compound component pattern can help you create powerful, more flexible, and reusable components. These patterns empower developers to avoid bloated props and give them greater control over the layout and behavior of components.

A Bad Example, and Why It’s Problematic

We’ll start by considering a common scenario where you’re building a custom Dropdown component for a UI library. A straightforward but flawed implementation might look like this:


const Dropdown = ({ label, options, selectedValue, onChange, isOpen, toggle }) => {
  if (!isOpen) return null;
  return (
    <div className="dropdown">
      <button onClick={toggle}>{label}</button>
      <ul>
        {options.map((option) => (
          <li key={option.value}>
            <button
              onClick={() => onChange(option.value)}
              className={option.value === selectedValue ? 'active' : ''}
            >
              {option.label}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};
export default Dropdown;

Usage:


import Dropdown from "./Dropdown";

function App() {
  const [selectedValue, setSelectedValue] = useState(null);
  const [isOpen, setIsOpen] = useState(false);
  const options = [
    { value: "apple", label: "Apple" },
    { value: "banana", label: "Banana" },
  ];
  
  return (
    <div>
      <Dropdown
        label="Select Fruit"
        options={options}
        selectedValue={selectedValue}
        onChange={setSelectedValue}
        isOpen={isOpen}
        toggle={() => setIsOpen(!isOpen)}
      />
    </div>
  );
}
export default App;

1. Bloated Props Interface

When a component has too many props, like the Dropdown example above, it becomes harder to maintain and extend. Each new feature or behavior will likely require a new prop, which bloats the component’s interface.

Imagine you want to add a feature to disable options. To support this, you would add a disabledOptions prop:


const Dropdown = ({ label, options, selectedValue, onChange, isOpen, toggle, disabledOptions }) => {
  if (!isOpen) return null;
  return (
    <div className="dropdown">
      <button onClick={toggle}>{label}</button>
      <ul>
        {options.map((option) => (
          <li key={option.value}>
            <button
              onClick={() => onChange(option.value)}
              className={option.value === selectedValue ? 'active' : ''}
              disabled={disabledOptions.includes(option.value)}
            >
              {option.label}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

The more features you add, the more props you need, and over time, this leads to an unwieldy and hard-to-understand component interface. Now, every time you maintain or update the component, you must handle the added complexity of these props.

2. Limited Customization

In the current Dropdown implementation, the structure and behavior are tightly coupled inside the component. If you want to customize any part of the dropdown (like the button or ul tags), you’re forced to modify the component directly.

What if you wanted to add a header inside the dropdown or change the appearance of the dropdown based on user preference?


<Dropdown
  label="Select Fruit"
  options={options}
  // How would you add a custom header inside the dropdown?
/>

There’s no way to modify the internal layout without editing the component itself, making it less flexible for different use cases.

3. Inflexible Structure

Since the dropdown structure is rigid, adding new elements or changing behaviors is difficult. If a developer wants to group the options or insert a custom footer, they’d need to either modify the existing component or recreate it from scratch.

Imagine you need to group your dropdown options under categories. With the current setup, there’s no easy way to do this:


// You'd need to add extra logic to support groups within Dropdown.js
const groupedOptions = {
  fruits: [{ value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }],
  vegetables: [{ value: 'carrot', label: 'Carrot' }, { value: 'potato', label: 'Potato' }]
};

<Dropdown
  label="Select Item"
  options={groupedOptions}
  // But how would you show the group headings?
/>

The Dropdown component would need additional logic to handle this grouping, further complicating the structure. This leads to a rigid component that’s difficult to extend without breaking the current implementation.

How to Solve It

To overcome these limitations, we’ll refactor the Dropdown component using composition and the compound component pattern. This approach will provide developers with greater flexibility and control while keeping the component’s logic encapsulated.

Composition First

Composition in React allows you to build complex components by combining simpler, more focused ones. Instead of passing everything through props, you can use children to let developers compose components according to their needs.

Implementation


import React from 'react';

const Dropdown = ({ isOpen, children }) => {
  return isOpen ? <div className="dropdown">{children}</div> : null;
};

export default Dropdown;

Usage


import React, { useState } from 'react';
import Dropdown from './Dropdown';

function App() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Select Fruit</button>
      <Dropdown isOpen={isOpen}>
        <ul>
          <li>
            <button onClick={() => alert('Apple selected')}>Apple</button>
          </li>
          <li>
            <button onClick={() => alert('Banana selected')}>Banana</button>
          </li>
          {/* More options */}
        </ul>
      </Dropdown>
    </div>
  );
}
export default App;

Now we have greater flexibility. Developers can structure and style the dropdown content as they see fit, inserting custom elements or grouping options. The component also requires fewer props, making it easier to understand and use.

Compound Your Components

While composition improves flexibility, the compound component pattern takes it a step further by providing a set of components that work together seamlessly. These components share state implicitly, allowing developers to build complex interfaces without prop drilling or excessive state management.

Implementation

First, our end goal is to have different components that compound into a Dropdown. So we might think of having a separate component for the Dropdown’s options, for its menu, and for its toggle. Since multiple components are sharing state under the Dropdown, we can begin by making use of React’s Context API to provide the state functionality.


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

const DropdownContext = createContext();

const Dropdown = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <DropdownContext.Provider value={{ isOpen, setIsOpen }}>
      <div className="dropdown">{children}</div>
    </DropdownContext.Provider>
  );
};

export default Dropdown;

Now we have a container that can manage whether the dropdown is open or closed.

Next, we might need a Toggle component that will handle opening and closing the dropdown.


import React, { useContext } from 'react';
import { DropdownContext } from './Dropdown';

const Toggle = ({ children }) => {
  const { isOpen, setIsOpen } = useContext(DropdownContext);
  return (
    <button onClick={() => setIsOpen(!isOpen)}>
      {children} {isOpen ? '▲' : '▼'}
    </button>
  );
};

export default Toggle;

The Menu component will then show or hide based on the dropdown’s open state.


import React, { useContext } from 'react';
import { DropdownContext } from './Dropdown';

const Menu = ({ children }) => {
  const { isOpen } = useContext(DropdownContext);
  return isOpen ? <ul className="dropdown-menu">{children}</ul> : null;
};

export default Menu;

Afterwards, each dropdown option will be represented by the Item component. This will trigger the action passed via the onSelect prop, allowing us to handle the selection of an option.


import React from 'react';

const Item = ({ children, onSelect }) => {
  return (
    <li className="dropdown-item">
      <button onClick={onSelect}>{children}</button>
    </li>
  );
};

export default Item;

To make it easier for developers to use, we attach all the subcomponents (Toggle, Menu, Item) to the main Dropdown component. This packages everything into a clean API that emphasizes their relationship.


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

export default Dropdown;

Full Code


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

const DropdownContext = createContext();

const Dropdown = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <DropdownContext.Provider value={{ isOpen, setIsOpen }}>
      <div className="dropdown">{children}</div>
    </DropdownContext.Provider>
  );
};

const Toggle = ({ children }) => {
  const { isOpen, setIsOpen } = useContext(DropdownContext);
  return (
    <button onClick={() => setIsOpen(!isOpen)}>
      {children} {isOpen ? '▲' : '▼'}
    </button>
  );
};

const Menu = ({ children }) => {
  const { isOpen } = useContext(DropdownContext);
  return isOpen ? <ul className="dropdown-menu">{children}</ul> : null;
};

const Item = ({ children, onSelect }) => {
  return (
    <li className="dropdown-item">
      <button onClick={onSelect}>{children}</button>
    </li>
  );
};

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

export default Dropdown;

Usage

With everything set up, using the dropdown becomes straightforward. Developers can compose the dropdown with a Toggle, Menu, and multiple Item components.


import React from 'react';
import Dropdown from './Dropdown';

function App() {
  const handleSelect = (value) => {
    alert(`${value} selected`);
  };

  return (
    <div>
      <Dropdown>
        <Dropdown.Toggle>Select Fruit</Dropdown.Toggle>
        <Dropdown.Menu>
          {/* You could put a header here if you'd like */}
          <Dropdown.Item onSelect={() => handleSelect('Apple')}>Apple</Dropdown.Item>
          <Dropdown.Item onSelect={() => handleSelect('Banana')}>Banana</Dropdown.Item>
          {/* More items */}
        </Dropdown.Menu>
      </Dropdown>
    </div>
  );
}

export default App;

Conclusion

Composition is a powerful tool and fits in with the mental model of React. Remember, the users of your components are developers. So as far as possible, control should be given to them. Strike a thin balance between inversion of control and internal functionality.

This approach empowers developers to control the component’s structure and behavior without being constrained by a prop-heavy interface.

Here’s a quick way to remember the takeaways. I will call it the 3Cs:

  • Compose: Allow developers to compose components, giving them control over content and layout.
  • Context: Share implicit state with the Context API.
  • Compound: Provide a set of components that uses that state, enhancing flexibility.

Bonus: Headless UI

We can take this pattern a step further by implementing a fully headless UI. Sometimes this may not be applicable as we want to stick to a certain styling, which is why this is in the bonus section.

What is Headless UI?

Headless components provide functionality without enforcing a specific UI structure, giving developers maximum flexibility for styling and layout. They focus purely on behavior, leaving the presentation entirely to you.

Headless Dropdown Example

We’ll refactor our dropdown to use a headless approach where we provide only the logic, allowing full control over the UI.

  1. We create a custom hook (useDropdown) that manages the dropdown state and behavior, like opening and closing.

import { useState } from 'react';

export const useDropdown = () => {
  const [isOpen, setIsOpen] = useState(false);
  const toggleDropdown = () => setIsOpen((prev) => !prev);
  const closeDropdown = () => setIsOpen(false);

  return {
    isOpen,
    toggleDropdown,
    closeDropdown,
  };
};
  1. The headless Dropdown component then provides state and behavior, but the UI is defined by the user.

import { useDropdown } from './useDropdown';

const Dropdown = ({ children }) => {
  const dropdown = useDropdown();
  return children(dropdown);
};

export default Dropdown;
  1. The developer controls the UI entirely while the Dropdown component handles the logic.

import Dropdown from './Dropdown';

function App() {
  return (
    <Dropdown>
      {({ isOpen, toggleDropdown, closeDropdown }) => (
        <div>
          <button onClick={toggleDropdown}>
            Select Fruit {isOpen ? '▲' : '▼'}
          </button>
          {isOpen && (
            <ul>
              <li>
                <button onClick={() => { alert('Apple selected'); closeDropdown(); }}>Apple</button>
              </li>
              <li>
                <button onClick={() => { alert('Banana selected'); closeDropdown(); }}>Banana</button>
              </li>
            </ul>
          )}
        </div>
      )}
    </Dropdown>
  );
}

export default App;

This approach is commonly used in libraries such as:


Back to blog