React Compound Component Guidelines
Table of Contents
- Designing defensive slots
- API Design
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.
‘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
Apply Props to Children
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).
React.cloneElement when iterating over children.
This allows you to add props to the children.
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.
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.
active tab case can be styled like so:
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.