Compound Components in React
As part of the Design System (DS) squad at Unibuddy, my day to day role as a Software Engineer extends to creating and improving our internal design system, defining the requirements of DS components and implementing best practices around accessibility, documentation and testing.
As you might have also experienced, building DS components involves thinking extensively about a component’s api and how it can be implemented. I’ve learnt the hard way that components with cluttered apis and tightly coupled use cases are difficult for engineers to get their heads around.
I’m now going to give an example of how to utilise the compound component pattern that helps overcome these issues (such as tightly coupled use cases and cluttered api’s) by building an accordion menu in a way that meets accessibility and best practice standards.
Component Implementation:
Let’s quickly implement a new accordion component, but for now we won’t worry about how its composed or any specific design patterns, we’re just concerned that it looks like this:
Ok lets get to it! We’ve got a tight deadline and just need to get something working that has some semblance of an accordion when you click on it. I imagine that most of us would create something like this at first:
Our job is done! Well, aside from a few glaring best practice and accessibility issues… Time to grab the next ticket from the backlog. Right?
Well, what would happen if I wanted to also pass through a defaultExpanded
prop?
And maybe we need to have an onClick
handler too …
What about a custom className
…
What happens if another team wanted to use this component and enable all of the accordion items to open at once. Thats another prop right? Suddenly we have component that has an extensive and expanding list of possible props as potential use cases stack up.
This ticket now has a lot of moving parts just for what we thought would be a simple component. Was this scope creep that we could have foreseen?
Fortunately there’s a cleaner way to do this.
Introducing the compound component pattern:
Let’s discuss the compound component pattern. The objective of the compound component pattern is to have a clearer, more expressive and flexible api. Kent C Dodds succinctly explains them as:
Think of compound components like the
<select>
and<option>
elements in HTML. Apart they don't do too much, but together they allow you to create the complete experience. The way they do this is by sharing implicit state between the components. Compound components allow you to create and use components which share this state implicitly.— Kent C. Dodds
So perhaps our original implementation can be improved to use the compound component pattern. Let’s think about the example above. The <option>
element is a child of the <select>
element, its not hidden, but we know that state is shared implicitly between them.
Following the same pattern, we need to think about having a contextual wrapper around the child components, so that we can also pass implicit state between the parent and child.
Ideally, the end goal would be to have an Accordion component that can be created like the image below. As you can see, state will have to be passed implicitly between the two, and the only thing we as engineers care about is how many AccordionItems
are included.
So, how can we do this in React? There are several React apis that come to mind:
React .Children
We could use React.Children.map
, where we map over an array of child components, passing down props to them using the React.cloneElement
api. This could be great, all of the accordion’s children would get the props we pass down and implicit state would be shared between parent and child components.
However, there’s one notable drawback to this method. It lacks the flexibility we require in that we can only pass props down to the direct children of the parent component (which means you’ll either have to do another clone or prop drill). This wouldn’t be that useful if our AccordionItem component was made up of several custom sub components (e.g. layout components such as spacers, intermediate containers etc), as these subcomponents would also need to pass down state or contextual props.
What else could we use?
React Context
We may instead have more luck with a different React api called Context. Context differs from using React.Children
in that is designed to share data with an entire tree of React components, rather than direct descendants. This means that we can create a context specifically for the Accordion
, which would enable state to be consumed by any of its child components if we chose to do so.
Accordion Implementation:
Ok, so now we’ve decided on the React api we want to use, we can now implement the Accordion
component (which you can see below).
You’ll see that we have created an AccordionContext
using the createContext
method provided by the context api. The AccordionContext
comes with a Provider
method which allows components to access contextual changes via the value
prop. The AccordionContext
then wraps the children to enable subscription to its context.
We’ve also implemented a number of hooks from the React api in the Accordion component for state management and performance :
useState
creates a stateful value (activeItem
) and a function to update it (setActiveItem
). In this case,activeItem
represents the active accordion item that’s been selected. This also forms the basis of the implicit state we are passing between parent and child components, and takes a default value (defaultExpanded)
, which determines whether that accordion item should be expanded to the end user.useCallback
provides a hook that returns a memoized callback calledsetToggle
, which controls a user clicking on an individualaccordionItem
. The use ofuseCallback
here is to prevent unnecessary and expensive child components re-renders on state change.- Finally,
useMemo
creates a memoized value. LikeuseCallback
this will only create a new instance of itself if the dependency array has changed — its not strictly needed in this instance, but its helpful to prevent expensive and unnecessary re-rendering. Although perhaps in this example it’s a little contrived, as with the use ofuseCallback
.
So, just to recap the Accordion
component is now a contextual wrapper of its children. It manages state, which maintains a record of the activeItem
and utilises several memoization methods to optimise performance of our React component for the AccordionContext
provider.
AccordionItem:
Now that we’ve implemented our contextual wrapper, the Accordion
, we need to implement the individual accordion items so that they are able to subscribe to the AccordionContext
correctly.
To do this we have created a helper method called useAccordionContext
which is a custom hook enabling our child components to access the AccordionContext
via the React useContext
api. If you look closely, we’ve included a defensive strategy of checking that the hook returns a context correctly, if not we throw an Error
to warn the engineer implementing the code that the use of this function is out of scope of the provider.
Once we’ve extracted the context successfully we then use the AccordionContext
in a number of ways within the AccordionItem
.
- The stateful value
activeItem
is provided to the component to determine if theExpandableSection
should be shown by default. - The
setToggle
is used to determine the accordion item that has been toggled via theonClick
handler. - We are also able to determine whether or not an accordion item should have a background colour via the
striped
prop (you can see the implementation of this in the code sandbox below).
And thats it! We’ve just created a working Accordion
component, with multiple children, implemented using the Compound Component pattern which utilises a number of complex React Hooks and api’s 😅.
Have a look at the working implementation in the code sandbox below.
In Review:
The Accordion
component uses React Context
to store the current state, the state update function, default expanded state and whether the accordion should have a colour contrast between odd and even child components. We can add as many children as we want (including dumb components which have no access to the accordion context), without the need to change the api all the time or add any further props to it, and all the time state is shared implicitly between them.
This is the main principle and benefit of compound components — extensible, easy to use and flexible apis.
Further Reading:
If like me, it took you a little while to get your head around some of the more complex patterns in React, why not check out some of these other great resources that helped me out:
- Kent C Dodds — Compound Components in React
- Ryan Florence — Talk on Compound Components
We’re still hiring engineers!
Unibuddy’s growing and if you’d like to work for one of the fastest growing ed-tech firms feel free to look at our open engineer vacancies here.