React Compound Component Guidelines

@sirajchokshi

Table of Contents

  1. Designing defensive slots
    1. Ensure there is only one child
    2. Apply Props to Children
      1. React.cloneElement
      2. React.Children.map
  2. API Design
    1. Export Structure
    2. Styling States
    3. Controlled Components

Compound components are a pattern that allow your component to be composed of multiple sub-components put together outside of the component itself. This pattern is useful for components that have multiple parts that need to be controlled by the parent component.

The pattern became popular thanks to Reach UI and, more recently, Radix Primitives. These libraries utilize this pattern to offer ready-to-use accessibility and behavior. However, they delegate styling and composition to the user.

import * as Tabs from "@radix-ui/react-tabs";
 
export default () => (
  <Tabs.Root>
    <Tabs.List>
      <Tabs.Trigger value="1">1</Tabs.Trigger>
      <Tabs.Trigger value="2">2</Tabs.Trigger>
    </Tabs.List>
    <Tabs.Content value="1">Tab 1 contents</Tabs.Content>
    <Tabs.Content value="2">Tab 2 contents</Tabs.Content>
  </Tabs.Root>
);

‘Users’ in this context refers to the developer using the library, not the end-user of the application.

Users can define their own markup using this API. The parent component, controlled by the library developer, can still manage state and behavior.

If you want to read more about compound components, I recommend this article by Kent C. Dodds.

This remainder of this post contains some guidelines I’ve come up with for creating compound components after both consuming and implementing them.

Designing defensive slots

In the compound pattern, slots are represented as children of sub-components as opposed to props. React understands children as an array of ReactNode regardless of the use case, so it’s important to design your slots defensively.

Ensure there is only one child

If there should only be one child, use React.Children.only to ensure that there is only one child. This will throw an error if there is more than one child.

This is useful for slots that should only have one child. Common examples are icon wrappers like <Button.Append>, <Input.Prepend>, or <Input.Icon>.

const iconWrapperCSS = css`
  width: 2em;
  height: 2em;
  display: flex;
  align-items: center;
  justify-content: center;
 
  svg {
    width: 1.5em;
    height: 1.5em;
  }
`;
 
const IconWrapper = ({ children }) => {
  const { toggleMenu } = useMenuContext();
 
  // assert that there is only one child
  const child = React.Children.only(children);
 
  return (
    <div css={iconWrapperCSS}>
      {React.cloneElement(child, {
        onClick: () => toggleMenu(),
      })}
    </div>
  );
};

Apply Props to Children

React.cloneElement

If you need to add props to children, use React.cloneElement to add props to children. This is useful for slots that need to pass props, such as listeners, to a child.

Radix establishes the convention of using the asChild prop to indicate that the child should be cloned. Without this prop, the children are rendered into the default element (e.g., a button for a trigger).

const AccordionTrigger = ({ children, asChild }) => {
  const [isOpen, toggleOpen] = useReducer((open) => !open, false);
 
  if (asChild) {
    // assert that there is only one child
    const child = React.Children.only(children);
 
    // clone the child with listener and state
    return React.cloneElement(child, {
      onClick: toggleOpen,
      "aria-expanded": isOpen,
    });
  }
 
  return <button onClick={toggleOpen}>{children}</button>;
};

React.Children.map

Use React.Children.map with React.cloneElement when iterating over children. This allows you to add props to the children.

const Tabs = ({ children }) => {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      {React.Children.map(children, (child, index) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            "data-active": index === activeTab,
            onClick: () => setActiveTab(index),
          });
        }
      })}
    </div>
  );
};

API Design

Export Structure

Compound components allow users to import from a single namespace for convenience. Sometimes, users may want to import a single component separately. They can do this to optimize for tree-shaking or to avoid naming collisions. For this reason, it’s important to export both un-prefixed and prefixed components.

import { Root } from "./components/root";
import { Item } from "./components/item";
import { Trigger } from "./components/trigger";
import { Content } from "./components/content";
 
export {
  // un-prefixed exports for namespacing
  Root,
  Item,
  Trigger,
  Content,
 
  // exports for prefixed, non-namespaced imports
  MenuRoot,
  MenuItem,
  MenuTrigger,
  MenuContent,
};

Styling States

Compound components are often used for components that have multiple states. For example, a menu can be open or closed. A tab can be active or inactive. A switch can be on or off.

When providing a component that has multiple states, it’s important to provide a way to style each state. Accessible components should use ARIA attributes to indicate state, but these do not provide enough coverage and can be unintuitive as selectors.

For example, a menu can be open or closed. The aria-expanded attribute is used to indicate this state. While workable, a declarative set of data-* attributes allows for a more user-focused API.

Tabs.tsx
const Tabs = ({ children }) => {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      {React.Children.map(children, (child, index) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            // add data-active attribute to indicate state
            "data-active": index === activeTab,
            onClick: () => setActiveTab(index),
          });
        }
      })}
    </div>
  );
};

Then the active tab case can be styled like so:

styles.css
[data-active="true"] {
  background: var(--color-primary);
  color: var(--color-white);
}

Controlled Components

Complex or multi-step interfaces, like forms, demand inversion of control. In other words, the parent component should control the state of the child. A rich API should allow for controlled components as an opt-in.

This can be done by accepting a prop to use as state and defaulting to internal state when the prop is not defined. Additionally, a callback for updates (e.g., selecting a tab) should be provided by the user.

Tabs.tsx
type TabControlledProps = {
  current: number;
  setCurrent: (index: number) => void;
};
 
type TabsProps = Partial<TabControlledProps> & PropsWithChildren;
 
const Tabs = (props: TabProps) => {
  const [currentTab, setCurrentTab] = useState(0);
  const { current: controlledCurrent } = props;
 
  // if the current prop is defined, use it as the state
  const activeTab = controlledCurrent ?? currentTab;
  // if the current prop is defined, use the provided callback to update it
  const setActiveTab = controlledCurrent ? props.setCurrent : setCurrentTab;
 
  return (
    <div>
      {React.Children.map(props.children, (child, index) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            // use calculated state
            "data-active": index === activeTab,
            onClick: () => setActiveTab(index),
          });
        }
      })}
    </div>
  );
};