React Compound Component Guidelines
Table of Contents
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.
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:
[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.
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>
);
};