š§© Building Flexible UIs with the Compound Components Pattern in React
A compound component is a group of components that work together to form a cohesive UI unit. If youāve used native HTML form elements, youāve already seen this in action:
<select><option value="1">Option 1</option><option value="2">Option 2</option></select>
Here, <select>
manages the state, and <option>
elements describe how the select behaves. The options donāt hold any state themselves, but they rely on the context of the <select>
component to work correctly.
This declarative design is what we aim to replicate in React with compound components.
Letās say you want to build your own custom dropdown:
<CustomSelectoptions={[{ value: '1', display: 'Option 1' },{ value: '2', display: 'Option 2' },]}/>
It works, but itās rigid. What if you want to style individual options differently? Or run logic based on which option is selected?
To handle all that, youād need to bloat your component with extra props and APIs. Thatās where the compound component pattern comes ināto give developers control over presentation and flexibility without overcomplicating the API.
For the rest of this example, weāll focus on a simpler case: a <Toggle />
component.
Your typical implementation might look like this:
<Toggleon={isOn}toggle={handleToggle}/>
But what if you want to conditionally render content inside the toggle based on its state? Maybe something like:
<Toggle><ToggleOn>ā The toggle is ON</ToggleOn><ToggleOff>ā The toggle is OFF</ToggleOff><ToggleButton /></Toggle>
Here, <Toggle>
manages the state, and the child components (<ToggleOn>
, <ToggleOff>
, and <ToggleButton>
) react to that state. This is the compound component pattern in action.
Unlike prop drilling, where data flows explicitly through props, compound components rely on implicit state sharing. The consumer of the API (developer using <Toggle>
) doesnāt have to know anything about how the state is managed.
That sounds magical⦠but it means you (as the library author) must make the state available internally across components.
Enter: React Context.
Letās walk through what we want to accomplish.
<Toggle />
: the parent that manages state (on
) and provides a toggle
function<ToggleOn />
: shows its children when on === true
<ToggleOff />
: shows its children when on === false
<ToggleButton />
: renders a toggle button using internal state and toggle
To make this work, weāll use React.createContext
to create a ToggleContext
and use useContext
inside each compound component.
// toggle.tsxconst ToggleContext = React.createContext(null)export function Toggle({ children }) {const [on, setOn] = useState(false)const toggle = () => setOn(o => !o)return (<ToggleContext.Provider value={{ on, toggle }}>{children}</ToggleContext.Provider>)}
And inside our compound components:
function useToggleContext() {const context = useContext(ToggleContext)if (!context) {throw new Error('Toggle compound components must be rendered within a <Toggle />')}return context}export function ToggleOn({ children }) {const { on } = useToggleContext()return on ? children : null}export function ToggleOff({ children }) {const { on } = useToggleContext()return on ? null : children}export function ToggleButton() {const { on, toggle } = useToggleContext()return <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>}
Now developers can compose their UI however they want, with <Toggle>
managing the logic under the hood.
<Toggle />
?<ToggleButton />
This will break! Because thereās no provider above it, the context will be undefined
.
We can improve developer experience by throwing a helpful error in useToggleContext()
ā which we already did above. This makes debugging easy and educates the developer at the same time.
TypeScript will likely warn you that useContext(ToggleContext)
might be null
. Thatās because we initialized the context as null
. But thanks to our runtime check (that throws if context
is null
), we can use TypeScriptās non-null assertion pattern to safely type the context.
Hereās a safer and typed version:
type ToggleContextType = {on: booleantoggle: () => void}const ToggleContext = React.createContext<ToggleContextType | undefined>(undefined)
Now, TypeScript helps us ensure the context is always properly consumed.
The compound component pattern:
ā Promotes composition ā Provides flexibility to consumers ā Keeps logic encapsulated ā Creates clean and declarative APIs
Youāll find this pattern widely used in libraries like:
Shoutout to Ryan Florence who popularized this approach. š
Donāt jump straight into abstractions when youāre building components. Start with something simple and specific. But as your components grow in complexity and need to support more use cases, consider adopting the Compound Components Pattern.
It will make your components easier to scale, more expressive, and far more enjoyable for others to use.
Quick Links
Legal Stuff
Social Media