Compound Components

Published: 07 August 2019

Compound components provide a declarative API that can allow for some impressive solutions to everyday problems. But what are they? Let's step away from React for a second and look at the HTML table element.

The table element doesn't do too much on its own. To render a usable table we need to add some children (thead, tbody, tr, td etc.).

<table>
  <caption>
    Cats
  </caption>

  <thead>
    <tr>
      <th>Name</th>
      <th>Breed</th>
      <th>Age</th>
      <th>Kitten count</th>
    </tr>
  </thead>

  <tbody>
    <tr class="table-row">
      <td class="table-cell">Sharky</td>
      <td class="table-cell">Maine Coon</td>
      <td class="table-cell">4</td>
      <td class="table-cell">2</td>
    </tr>

    <tr class="table-row">
      <td class="table-cell">George</td>
      <td class="table-cell">British Shorthair</td>
      <td class="table-cell">6</td>
      <td class="table-cell">1</td>
    </tr>
  </tbody>
</table>

We have control over how many of these child elements we add, how we want to group our columns, what elements we want to bind events to and we can control all of this declaratively. Need to style your row? Just add a class or style attribute to it directly.

Meanwhile, behind the scenes, the browser uses the information you provide to construct a table the way you have requested that behaves how the specification says a table should. This is a compound component.

God-like alternative

Let's return to React and see how a God-like alternative to this might compare and why the compound component approach is often more beneficial. We'll be using the table below as the basis for our comparison.

<Table
  caption="Cats"
  columns={columns}
  rowData={cats}
  rowClassName="table-row"
  cellClassName="table-cell"
  onRowClick={handleRowClick}
/>

Config driven for consumers

The God-like approach requires every implementation of the table to ensure it either transforms the passed rowData into a format that the table expects by default, or tell it where it can find the values in the data set using some predefined configuration format.

const Cats = () => {
  // A query that gets cats data structure from server
  const cats = useGetCats();
  const columns = [
    {
      label: 'Name',
      valueGetter: ({ data }) => data.name,
    },
    {
      label: 'Breed',
      valueGetter: ({ data }) => data.breed,
    },
    {
      label: 'Age',
      valueGetter: ({ data }) => data.age,
    },
    {
      label: 'Kitten count',
      valueGetter: ({ data }) => data.kittens.length,
    },
  ];

  // ...

  return (
    <Table
      caption="Cats"
      columns={columns}
      rowData={cats}
      rowClassName="table-row"
      cellClassName="table-cell"
      onRowClick={handleRowClick}
    />
  );
};

This can end up being a significant amount of additional code when consumed several times and usually means a notable portion of the developers time is spent reading documentation to find out the configuration or data structure your component expects every time they want to implement it. With a compound component they can just do the following:

const Cats = () => {
  const cats = useGetCats();

  // ...

  return (
    <Table>
      <TableCaption>Cats</TableCaption>
      <TableHead>
        <TableRow>
          <TableCell>Name</TableCell>
          <TableCell>Breed</TableCell>
          <TableCell>Age</TableCell>
          <TableCell>Kitten count</TableCell>
        </TableRow>
      </TableHead>
      <TableBody>
        {cats.map((cat) => (
          <TableRow key={cat.id} className="table-row" onClick={handleRowClick}>
            <TableCell className="table-cell">{cat.name}</TableCell>
            <TableCell className="table-cell">{cat.breed}</TableCell>
            <TableCell className="table-cell">{cat.age}</TableCell>
            <TableCell className="table-cell">{cat.kittens.length}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
};

The compound component doesn't need to be told where to find and put anything because the consumer has the flexibility to put bits where they need by themselves, thus resulting in less code/responsibility for the component maintainers and zero config objects or data transformations for the consumer 😍.

Maintenance overhead

We can see pretty quickly how we could end up with a long list of row, column, or cell prefixed props using the God-like alternative and this is common across most God-like implementations. They end up with prefixed props that have to be passed along internally to the relevant elements.

This can become restrictive. What if our consumer wants a new handler? For example, an onCellClick handler for the "Kitten Count" cell? We'd have to release a new version with a new event handler prop that enables the consumer to identify which cell was clicked. The consumer then needs to make sure it's the cell they expect before doing what they need.

With the compound component approach, the consumer gets that for free without any work from the maintainers or the need to verify which cell it is because they can bind to the cell they want directly.

// ...
<TableCell onClick={handleKittensCountClick}>{cat.kittens.length}</TableCell>
// ...

Difficult to paint a mental picture

Personally, when I need to know what's being rendered by a component and how it will potentially look, I head straight for the return in a functional component or the render method in class-based components. If I then have to follow abstractions to paint this picture it means additional processing I have to maintain in memory, and we all know humans aren't great at that. It slows developers down and can often lead to human error.

The God-like approach is a monolith of abstractions. We can't look at the return and paint a picture of how many columns will be rendered, or what data will be rendered in which columns. The compound component, on the other hand, has it all laid out clearly.

So how do we build one?

Instead of exposing one component, expose a parent component and its children. The tricky part is when the components need to communicate or modify each other at an API level.

To do this, we can combine the React.Children methods with React.cloneElement to modify children props but this requires that the child components are immediate descendants and also exposes those props to consumers which you may have intended to be private.

The alternative is to use the Context API. It allows parents to communicate with children at a deeper level and you do not need to hijack props allowing a more private internal API. Imagine a contrived example of a component that has an isSmall prop and all its children need that information also.

<List isSmall>
  <ListItem isSmall>Cat</ListItem>
  <ListItem isSmall>Dog</ListItem>
</List>

This is a little bit tedious for the consumer and also means they could make only some of the items small. But what if we want them to be consistent? We can DRY this up and maintain consistency if we use the Context API to share the parent prop with its children.

import React, { useContext } from 'react';

const Context = React.createContext();

const List = ({ isSmall = false, children, ...props }) => (
  <ul {...props} style={{ padding: isSmall ? '5px' : '10px' }}>
    <Context.Provider value={isSmall}>{children}</Context.Provider>
  </ul>
);

const ListItem = ({ children, ...props }) => {
  const isSmall = useContext(Context);

  return (
    <li {...props} style={{ padding: isSmall ? '5px' : '10px' }}>
      {children}
    </li>
  );
};

export { List, ListItem };

Notice how there is no isSmall prop for the ListItem anymore so we prevent the consumer from creating inconsistencies but it can still reference the one from the parent and be consumed as follows:

<List isSmall>
  <ListItem>Cat</ListItem>
  <ListItem>Dog</ListItem>
</List>

Conclusion

To recap:

  • If you find yourself passing config objects/arrays of objects as props to your components, be aware that consumers will often have to transform their data to get it into the correct format first. A compound component can prevent that additional overhead if you provide a child component with the object properties as its props.

  • If you find yourself repeating a prefix amongst your props, try converting that prefix into a child component with props. It will give more control to the consumer and mean less maintenance for you in the long run.

  • Compound components help to avoid render abstractions making it easier for developers to paint a mental picture of what's being rendered.

If you'd like some more information on them, I would highly recommend Kent C. Dodds' Write Compound Components egghead course.

© 2024