The composition king: compound components
Published on:Table of Contents
Repeatedly adding orthogonal properties to a React component causes unwieldy bloat. Transitioning to a compound component will invert control and allow clients the flexibility they need. It comes at a cost, though. What are these costs, can we mitigate them, and what are alternative solutions?
Let’s build a motivating example of a component’s growth. Say we’re building an atlas web app and there will be flags everywhere.
Our first task is to create a React component to display a flag:
// "tag" can be the id to look up the src of image
<Flag tag="DAN" name="Denmark"/>
New request: flag may be displayed in several sizes:
<Flag tag="DAN" name="Denmark" size="xs"/>
<Flag tag="DAN" name="Denmark" size="base"/>
<Flag tag="DAN" name="Denmark" size="large"/>
New request: optionally display name of country alongside as most users aren’t experts in vexillology:
<Flag tag="DAN" name="Denmark" showName/>
You might be thinking, “finally the name prop is being used”, but don’t forget that the name should have been used as the image’s alt
text. It’s only with showName
that we can mark the image as decorative and assign an empty alt
text.
New request: optionally make component a button to trigger a dialog with more information:
<Flag tag="DAN" name="Denmark" showName isTrigger/>
My design oriented readers can let us know how much of a sin I have committed by having a sharp focus visible outline on one side of the button and rounded on another.
New request: add a tooltip that informs the user of a country’s full name if not displayed.
We can make the observation that we can derive the tooltip behavior without a new prop
<Flag tag="DAN" name="Denmark"/>
New request: be able to add a prefix to the name of country, increase font size, align the text to the bottom, and flip the location with the image:
<Flag
tag="DAN"
name="Denmark"
reverseOrder
isTrigger
withName={(name) => (
<div className="text-lg self-end">(#1) {name}</div>
)}
/>
If we hadn’t received all those requests at once, our component probably would have grown to look like:
<Flag
tag="DAN"
name="Denmark"
isTrigger
reverseOrder
withNamePrefix="(#1)"
alignItems="end"
textClassNames="text-lg"
/>
How brittle! Our component originally started so sweet and now has grown to something repulsive, and we aren’t even looking at the implementation. I know I have questions. Like does reverseOrder
do nothing unless either showName
or withNamePrefix
is used?
Hopefully the pattern is becoming clear. This component has many use cases and it needs to be flexible enough to adapt to each one. Each new prop is pulling it in a different direction, and it’s likely that future requests will mean further modifications and more props.
And more props = more names, and coming up with good names can be excruciating.
We find ourselves in the same situation as Tanner Linsley in his React Table v6 release retrospective:
I did the only thing I could, I added more props
So we can lie to ourselves and say this will be the last time we add props, or we can fix this.
Compound components
Our solution, like the solution for React Table v7 is to invert control. Instead of a single component with an ever-growing number of props relating to layout and styling, we can shatter it into many components and give the layout responsibility to the caller.
React Table inverted control by becoming a headless UI and embracing hooks for driving everything.
We don’t quite need to go that far – a compound component is sufficient.
This isn’t an in-depth tutorial on compound components, but as a refresher, compound components draw their shared props through a context.
When designing a component API, I like seeing usage and working backwards, and our previous <Flag>
example turned into a compound component would look like:
<Flag tag="DAN" name="Denmark">
<Flag.Tooltip asChild>
<Flag.DrawerTrigger className="flex gap-2">
<div className="text-lg self-end">(#1) <Flag.CountryName /></div>
<Flag.Image size="large" />
</Flag.DrawerTrigger>
</Flag.Tooltip>
</Flag>
It’s more code, but we have the lego pieces to build whatever we want.
<Flag>
creates and instantiates aFlagContext
- All
Flag.*
components like<Flag.Image>
and<Flag.CountryName>
pull from the context without needing for flag tag or name to be restated. We can call the hook pulling from this context asuseFlag
. - Removing flag interactivity is as simple as removing
<Flag.DrawerTrigger>
. - Better yet, it’s significantly clearer what we’re styling the trigger with the
className
. - The caller is able to create the layout that suits their situation
- And for those curious about the
asChild
trick, it’s a great way for an interactive element (like a tooltip) to be standalone as well as merge itself with its child so it doesn’t violate interactivity rules (like nested<button>
).
To me, the most inconvenient part of creating compound components is Typescript support (and this pains me as a huge Typescript nerd), as the presence of forwardRef
confounds everything. I’ve found the most success structuring compound components like the following:
const RootFlag = (/* ... */) => { /* ... */ };
export const Flag = RootFlag as typeof RootFlag & {
Tooltip: typeof FlagTooltip;
// ...
};
const FlagTooltip = (/* ... */) => { /* ... */ };
Flag.Tooltip = FlagTooltip;
// ...
The alternative solutions for Typescript compound components are equally good.
There’s nothing special about the “dot notation”, the components don’t need to be cajoled all under <Flag>
and instead could use a “Flag” as a prefix as their name, but I find it more useful to see the dot as it results in less imports and fewer naming collisions.
Detecting children
Compound components aren’t without their drawbacks. By pushing control of the components to the client, we are making the jobs of the component consumer and the creator more difficult.
One of our requests was for the country name to be included in the tooltip if it is not shown (and we’d need to use it as alt text for the image).
How does <Flag.Tooltip>
know if the full country name is shown so it can adapt its contents accordingly? The first problem we need to solve is a way for the tooltip and country name to find each other.
Broadly there are two communication channels for this: on mount events and an eager search.
On mount detection
When our child component is mounted, we register it with our flag context:
const FlagCountryName = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(function FlagCountryName(props, ref) {
const flag = useFlag();
useLayoutEffect(() => {
flag.actions.incrementCountryNames();
return () => {
flag.actions.decrementCountryNames();
}
}, [flag.actions]);
return (
<span ref={ref} {...props}>
{flag.name}
</span>
);
});
Then the tooltip can listen for changes of the count of country names and when it exceeds 0, the tooltip can display the name.
Even if I leave the action implementations as an exercise to the reader there is still quite a bit going on.
useLayoutEffect
is used as the contents of another component is dependent on <Flag.CountryName>
being mounted and we don’t want a flash of unintended content. The dependent component in this case is a tooltip, so useLayoutEffect
vs useEffect
is a moot point, as a tooltip is triggered by user actions, which necessitates <Flag.CountryName>
already being mounted.
Note that useLayoutEffect
should be stubbed when used outside of the browser to avoid errors.
Eager detection
For better or worse, eager detection is a bit more clever of a solution.
It relies on the parent traversing children while rendering, and it requires us to write a function to find our “needle” component in this child haystack.
import React from "react";
/// Returns true if any children or further
/// descendants contains a given component.
export const hasDescendant = (
children: React.ReactNode,
needle: (...args: any[]) => React.ReactNode
): boolean => {
let found = false;
React.Children.forEach(children, (child) => {
const valid = React.isValidElement(child);
if (!valid || found) {
return;
}
found ||= child.type == needle;
if (!found) {
found ||= hasDescendant(child.props.children, needle);
}
});
return found;
};
And is used like so:
function FlagTooltip({ children }) {
const flag = useFlag();
const hasFullName = hasDescendant(children, FlagCountryName);
return (
<Tooltip>
<Tooltip.Trigger>
{children}
</Tooltip.Trigger>
<Tooltip.Content>
{hasFullName ? flag.tag : `${flag.name} (${flag.tag})`}
</Tooltip.Content>
</Tooltip>
);
}
The benefits are immediate. We don’t need to wait for children to mount in order to display the correct tooltip. This makes the eager detection a great candidate for anything that should be rendered server side (or prerendered) as correct content can be immediately displayed.
Does this make sense for a tooltip, something that doesn’t appear until the user interacts with an element? No. Either detection method is suitable.
Though note, even in the perfect use cases, there is no free lunch:
hasDescendant
performs a depth first search. This means that despite the optimization of returning early when we find the given element, we still need to fully traverse any large component trees prior to our component.The tree traversal isn’t slow by any means, but it is something to be cognizant of. One can opt to use breadth first search, but this has the downside of needing<Container> <HugeTree/> <Needle/> </Container>
O(n)
space forn
components in the tree.- Hot module reload can get funky with
hasDescendant
as the needle component is replaced in-situ, and may receive a randomized name in order for hot module reload to work and trigger rerenders of affected components. I’ve been burned by this and it’s quite the head scratcher when it occurs.
Children.forEach
isn’t a very well known API and it may be anxiety inducing to see it labeled a “legacy API”, but you can find it used in several UI libraries:
And widening the search to any “legacy” Children API, like toArray
and map
, results pop up in Radix and Chakra, so don’t think these APIs will be removed anytime soon.
Explicit is better than implicit
Imagine a component tree where the country name is mounted but it or an intermediate ancestor is hidden:
{/* ... snip Flag ... */}
<div className="hidden">
<Flag.CountryName/>
</div>
Both methods of our detection logic will fail to display the correct long-form tooltip, as they don’t account for mounted but invisible components (like visibility: hidden
and display: none
).
This isn’t a terribly contrived example either. Imagine that we wanted the full name to be responsive and only show at larger screen sizes:
{/* ... snip Flag ... */}
<Flag.CountryName className="hidden sm:block"/>
Are we able to preserve the magic of influencing a component based on the status of its children? Kinda. There’s a new browser API: Element.checkVisibility()
, but it is not available on Safari. And even if the API was available, we’d lose the ability to correctly render server side as the API needs to run on the client.
This is just scratching the surface of that rabbit hole. We’d still need to figure out how to couple a change in visibility to a change in tooltip contents.
There is a cost to magic. When implicit isn’t foolproof, it is better to be explicit than implicit.
And this is one of the downsides to compound components. Since we have given up control, we may need additional props from client programmers in order to know the correct behavior.
Below I’ve introduced a showName
prop to the tooltip.
<Flag tag="DAN" name="Denmark">
<Flag.Tooltip asChild showName>
{/* ... */}
</Flag.Tooltip>
</Flag>
So we’re trading one component with “too many” props to many components with even more props. Ironic in some sense.
And showName
isn’t even the only prop we’d need to add, as we want to make sure we correctly mark the flag image as decorative when we are already communicating the country name to the browser:
<Flag tag="DAN" name="Denmark">
<Flag.Image decorative />
<Flag.CountryName />
</Flag>
Is it worth it? We now have two more props to think about. Well, like everything, it depends
I’ve omitted some discussion on responsiveness. If you’re hungry for more, I’ve written how to design responsive React components that are friendly to prerender and server side rendering.
Mitigating dissonance
A well-defined Typescript API is enough to prevent misuse of a standalone component, but transitioning to a compound component introduces dissonance with its infinite permutations, as each component may be correct in isolation but break down with a particular juxtaposition.
A compound component gives up its instruments for the consumer to create a symphony and not all symphonies sound good.
But this is nothing new, HTML gives us components to compose incorrectly:
<!-- nested buttons -->
<button>
<button>Click</button>
</button>
<!-- multiple H1s -->
<h1>Hello</h1>
<h1>World</h1>
There are a couple of techniques that we can employ to help prevent unintended compositions:
- Fail hard and fail fast
- Issue a warning and fail in development
A scenario where you’d want to fail hard and fail fast is when a component invariant has been violated and there is no recourse. In the case where a <Flag.Image>
is used without a <Flag>
ancestor to setup a context, we’d want our component to fail rendering. Hence the useFlag
hook is written like:
function useFlag() {
const context = useContext(FlagContext);
if (!context) {
throw new Error("Missing <Flag> ancestor");
}
return context;
}
By throwing an error while rendering we stand a greater chance to catch this mistake in tests and build steps (if they happen to render pages).
Failing hard and fast is for catching programmer mistakes.
Failing hard and fast is the easy approach, but it’s not always the right one.
It may be hard for clients to grok what is the correct way to leverage the compound component for their use case. Or the composition may be influenced by user data. In these cases we want to limp ahead.
A good example of this is the Radix UI dialog. Most developers treat accessibility as an afterthought and are likely to omit it, so the <Dialog>
component combats this by pre-allocating DOM ids for the title of the dialog, which the <Dialog.Title>
will render into. Then the dialog can check if the title exists via document.getElementById
and issue a warning in development if otherwise.
One can gate extra, potentially expensive, checks behind development mode with:
{process.env.NODE_ENV !== 'production' && (
<TitleWarning titleId={context.titleId} />
)}
But it looks like creating diligent checks isn’t widespread practice as Radix UI dialog suffers from the same issue we’ve discussed:
// Renders two elements with the same ID and with
// both hidden, the dialog lacks an accessible title.
// No errors or warnings
<Dialog.Title className="hidden">My Title</Dialog.Title>
<Dialog.Title className="hidden">My Title2</Dialog.Title>
Backwards Compatibility
What about clients who don’t need or don’t want control (and the pitfalls that come with it). Must we subject them to the increased complexity a migration to the compound component would entail?
No!
Introducing compound components can be done in a backwards compatible fashion by typing the props as a discriminated union where the presence of children is the discriminant.
If children are present, we know the client is opting into the compound component, and this can be typed as such:
type FlagContextState = { name: string; tag: string };
type CompoundProps = FlagContextState & { children: React.ReactNode };
type TemplateProps = FlagContextState & {
size?: "base" | "xs";
showName?: boolean;
isTrigger?: boolean;
// ...
}
const RootFlag = (props: CompoundProps | TemplateProps) => {
return (
<FlagContext.Provider value={{ name: props.name, tag: props.tag }}>
{"children" in props ? props.children : <FlagTemplate {...props} />}
</FlagContext.Provider>
);
};
If children
is present on both original and compound component, it can’t be used as our discriminant. The workaround, while preserving backwards compatibility, is to introduce a new property (eg: raw
) that represents opting into the compound component.
type CompoundProps = FlagContextState & { raw: true };
type TemplateProps = FlagContextState & {
raw?: false;
// ...
}
// ...
<Flag raw tag="DAN" name="Denmark">
<Flag.Image/>
{/* ... */}
</Flag>
If you’ll humor me for a bit more, we can take this further. I’d recommend introducing a third method for clients to consume flags. I call this method “variants”. They are a self-descriptive happy medium between repeatedly copying and pasting a compound component usage and adding props to the original component (something we’re trying to avoid).
Imagine we have a few pages where we display a flag as the main heading of the page. We should create a dedicated component for this built from our compound component.
const TitleFlag = ({ tag, name }) => {
return (
<div className="flex gap-2">
<Flag tag={tag} name={name}>
<Flag.Image size="large" decorative />
<h1 className="text-5xl tracking-tight font-bold text-gray-800">
<Flag.CountryName />
</h1>
</Flag>
</div>
);
}
For those disappointed that the above example uses an h1
wrapper instead of a substitution like:
<Flag.CountryName as="h1" />
Let’s save polymorphic React components in Typescript for another time.
One can build up a collection of these variants and integrate them into the original <Flag>
, using a similar discriminant strategy that we used earlier.
<Flag variant="title" tag={tag} name={name} />
These variant implementations can alleviate consumers from juggling if a flag image is decorative and tying the tooltip contents to the visibility of the country name.
It’s been quite a journey but we now offer 3 interfaces in 1:
- The original component for backwards compatibility
- The compound component for the ultimate flexibility
- Self descriptive variants for clients that don’t want the control that compound components bring and otherwise don’t want to discover the right combination of props to get the desired result
Conclusion
When you have a component constantly undergoing churn to support props that target its internal structure, layout, or style, it is a good candidate to be transitioned to a compound component, allowing clients to achieve their goals with the composition they require.
The caveat, as we’ve seen, is that compound components are not a silver bullet. Our standalone original component had everything under control, and by giving that up we widened the surface area for errors, and required more code and props. Variant implementations can be used to help abstract away some of the complexities that come with compound components.
Compound components: a tool to be used and a tool that can be abused.
Comments
If you'd like to leave a comment, please email [email protected]