React Pattern: Compound Component

When creating custom UI components in React, an easy to evolve and implement solution is to add more props to an element to describe what to show on screen and what functions to call. It enables us to create a lot with a low level of abstraction and is usually a good choice.

One problem arises when creating a component used by a third party or another team (like in a component library). In this case, we would like to provide a more customisable approach to give more UI freedom to the developers.

A technique generally referred to as “Compound Component” allows us to simplify the composability of our App.

What does it look like?

Let’s say we want to replicate the “select”/“option” interaction but allow more freedom over the UI. This is our HTML starting point:

<select>
  <option value="A">Option A</option>
  <option value="B">Option B</option>
  <option value="C">Option C</option>
</select>

One way we could replicate it in React is this:

<Select defaultText="Select an option" selectedSymbol="✓">
 <Option value="A">Option A</Option>
 <Option value="B">Option B</Option>
 <Option value="C">Option C</Option>
</Select>

It mixes normal props and compound components, allowing us to compose the options’ UI in any way we want by using the predefined element: “Option”.

We can also use any standard HTML tag we want, as our component won’t touch them.

How does it work?

It relies on Context to share the state between the children, with each Option component taking care of event handling and the selected state.

We have two functions, one for the Context, which acts as “Select Container”, and one for the “Option” component.

Context / Select Container

Here we define the code that will drive our component logic and expose it via Context value.

Each child will be able to access “selectedValue”, “select” and “selectedSymbol”.

export const SelectContext = createContext(null);
export function Select({ children, defaultText, selectedSymbol }) {
  const [showOptions, setShowOptions] = useState(false);
  const [selectedValue, setSelectedValue] = useState();
  const select = (value) => {
    setSelectedValue(value);
    toggleShowOptions();
  };

  function toggleShowOptions() {
    setShowOptions(!showOptions);
  }

  return (
    <SelectContext.Provider value={{ selectedValue, select, selectedSymbol }}>
      <button className="select-button" onClick={toggleShowOptions}>
        {selectedValue || defaultText}
      </button>
      {showOptions && <ul className="select">{children}</ul>}
    </SelectContext.Provider>
  );
}

Option

Each option component will use Context to render itself and update the state when clicked.

export function Option({ value, children }) {
  const { selectedValue, select, selectedSymbol } = useContext(SelectContext);
  const selected = selectedValue === value;
  return (
    <li
      role="option"
      aria-selected={selected}
      className="option"
      onClick={() => select(value)}
    >
      {children}
      {selected && <span className="option-selected">{selectedSymbol}</span>}
    </li>
  );
}

Why Context?

This same example can be written without using “Context”, but without it, we would lose the freedom to put the components where we want without worrying about nesting them.

“Context” allows us to access the state wherever we need it. Without it, we would have to add our props manually by traversing the React children tree or limiting ourselves to a less extensible solution that only accepts supported components at the highest layer.

Code

The complete code of the example can be found here.