React-Hooks Compound Components#
Compound components are when you have two or more components working together to accomplish a useful task. Typically, one of the components is the parent and the others are children. The goal is to provide a more expressive and flexible API.
Think of it like <select>
and <option>
:
<select>
<option value="value1">key1</option>
<option value="value2">key2</option>
<option value="value3">key3</option>
</select>
If you try to use just one without the other, it won't work (or won't make sense). Additionally, this is actually a really great API. Let's see what it would look like if we didn't have the compound component API to use (remember, this is HTML and not JSX):
<select options="key1:value1;key2:value2;key3:value3"></select>
I'm sure you can think of other ways to express this, but it's pretty gross. And how would you express the disabled attribute with this API? It gets a little crazy.
So, the compound component API gives you a nice way to express the relationship between components.
Another important aspect is the concept of "implicit state". The <select>
element implicitly stores state about the selected option and shares that state with its child elements so they can render themselves based on that state. But this sharing is implicit and not accessible (nor needed) in our HTML code.
Alright, let's take a look at a valid React component that exposes compound components to further understand these principles. Here's an example of the <Menu />
component from Reach UI that exposes a compound component API:
function App() {
return (
<Menu>
<MenuButton>
Actions <span aria-hidden>▾</span>
</MenuButton>
<MenuList>
<MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
<MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
<MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
</MenuList>
</Menu>
)
}
In this example, <Menu>
establishes some shared implicit state. The <MenuButton>
, <MenuList>
, and <MenuItem>
components can all access or manipulate that state, and it's all done implicitly. This allows you to provide the expressive API you want.
So how is this done? Well, if you watch my course, I show you two ways to accomplish this. One using React.cloneElement
on the child elements, and the other using React context. (My course needs a slight update to show how to accomplish this with hooks). In this blog post, I'll show you how to create a simple set of compound components using context.
When teaching a new concept, I like to start with a simple example. So we'll use my favorite <Toggle>
component example to explain.
This is how we would use the <Toggle>
compound component:
function App() {
return (
<Toggle onToggle={(on) => console.log(on)}>
<ToggleOn>The button is on</ToggleOn>
<ToggleOff>The button is off</ToggleOff>
<ToggleButton />
</Toggle>
)
}
Alright, the moment you've all been waiting for, here's the actual code that implements the compound component using context and hooks.
import * as React from 'react'
// this switch implements a checkbox input and is not relevant for this example
import { Switch } from '../switch'
const ToggleContext = React.createContext()
function useEffectAfterMount(cb, dependencies) {
const justMounted = React.useRef(true)
React.useEffect(() => {
if (!justMounted.current) {
return cb()
}
justMounted.current = false
}, dependencies)
}
function Toggle(props) {
const [on, setOn] = React.useState(false)
const toggle = React.useCallback(() => setOn((oldOn) => !oldOn), [])
useEffectAfterMount(() => {
props.onToggle(on)
}, [on])
const value = React.useMemo(() => ({ on, toggle }), [on])
return <ToggleContext.Provider value={value}>{props.children}</ToggleContext.Provider>
}
function useToggleContext() {
const context = React.useContext(ToggleContext)
if (!context) {
throw new Error(`Toggle compound components cannot be rendered outside the Toggle component`)
}
return context
}
function ToggleOn({ children }) {
const { on } = useToggleContext()
return on ? children : null
}
function ToggleOff({ children }) {
const { on } = useToggleContext()
return on ? null : children
}
function ToggleButton(props) {
const { on, toggle } = useToggleContext()
return <Switch on={on} onClick={toggle} {...props} />
}
So how this works is we create a context with React where we store the state and mechanisms to update the state. Then the <Toggle>
component is responsible for providing that context value to the rest of the React tree.