Compound components are components that work together to form a complete UI. The
classic example of this is <select>
and <option>
in HTML:
<select><option value="1">Option 1</option><option value="2">Option 2</option></select>
The <select>
is the element responsible for managing the state of the UI, and
the <option>
elements are essentially more configuration for how the select
should operate (specifically, which options are available and their values).
Let’s imagine that we were going to implement this native control manually. A naive implementation would look something like this:
<CustomSelectoptions={[{ value: '1', display: 'Option 1' },{ value: '2', display: 'Option 2' },]}/>
This works fine, but it’s less extensible/flexible than a compound components
API. For example. What if I want to supply additional attributes on the
<option>
that’s rendered, or I want the display
to change based on whether
it’s selected? We can easily add API surface area to support these use cases,
but that’s just more for us to code and more for users to learn. That’s where
compound components come in really handy!
For the rest of the exercises in this workshop, we’ll be working with a simple
<Toggle />
component.
Every reusable component starts out as a simple implementation for a specific use case. It’s advisable to not overcomplicate your components and try to solve every conceivable problem that you don’t yet have (and likely will never have). But as changes come (and they almost always do), then you’ll want the implementation of your component to be flexible and changeable. One of the most important abilities of a software developer is optimizing for change. Learning how to do that is the point of much of this workshop.
This is why we’re starting with a super simple <Toggle />
component. You’ll be
surprised how feature-rich we can make a simple toggle component. Keeping it
simple allows us to focus in on making it reusable without getting distracted by
the complexities of the feature implementation (like we would if we were
building a date picker or something 😅).
Shout-out to Ryan Florence for creating this pattern.
Real World Projects that use this pattern:
@radix-ui/react-tabs
@radix-ui/react-accordion
👨💼 In this exercise we’re going to make <Toggle />
the parent of a few
compound components:
<ToggleOn />
renders children when the on
state is true
<ToggleOff />
renders children when the on
state is false
<ToggleButton />
renders the <Switch />
with the on
prop set to the on
state and the onClick
prop set to toggle
.We have a Toggle component that manages the state, and we want to render different parts of the UI however we want. We want control over the presentation of the UI.
🦉 The fundamental challenge you face with an API like this is the state shared
between the components is implicit, meaning that the developer using your
component cannot actually see or interact with the state (on
) or the
mechanisms for updating that state (toggle
) that are being shared between the
components.
So in this exercise, we’ll solve that problem by using the 📜 React Context API!
What we want to do in this exercise is allow users of our component to render something when the toggle button is on and to render something else when that toggle button is off without troubling them with managing the state that’s controlling whether it’s shown or not.
Your job will be to make a ToggleContext
which will be used to implicitly
share the state between these components. The Toggle
component will render the
ToggleContext
and the other compound components will access that
implicit state via use(ToggleContext)
.
🦺 TypeScript might not like your use
call depending on how you set up your
context. We’ll deal with this in another step.
👨💼 Change
to this (temporarily):import { ToggleButton } from './toggle'export const App = () => <ToggleButton />
Why doesn’t that work (it’s not supposed to, but can you explain why)? Can you figure out a way to give the developer a better error message that explains what they’re doing wrong and how to fix it?
🚨 The tests will tell you in the message for the error you must throw when the context is undefined.
🦺 Additionally, this is where we can make TypeScript happier (TypeScript knew about the problem we’d run into in this step of the exercise!). Remember, TypeScript isn’t making your life terrible. It’s just showing you how terrible your life already is 😂 In this exercise, we’re going to make our lives better.
(You can go ahead and undo the change to
if you’d like. The tests will let you know that you’ve gotten it right).import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'export function App() {return (<div><Toggle><ToggleOn>The button is on</ToggleOn><ToggleOff>The button is off</ToggleOff><ToggleButton /></Toggle></div>)}
import { createContext, use, useState } from 'react'import { Switch } from '#shared/switch.tsx'type ToggleValue = { on: boolean; toggle: () => void }const ToggleContext = createContext<ToggleValue | null>(null)export function Toggle({ children }: { children: React.ReactNode }) {const [on, setOn] = useState(false)const toggle = () => setOn(!on)return <ToggleContext value={{ on, toggle }}>{children}</ToggleContext>}function useToggle() {const context = use(ToggleContext)if (!context) {throw new Error('Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',)}return context}export function ToggleOn({ children }: { children: React.ReactNode }) {const { on } = useToggle()return <>{on ? children : null}</>}export function ToggleOff({ children }: { children: React.ReactNode }) {const { on } = useToggle()return <>{on ? null : children}</>}type ToggleButtonProps = Omit<React.ComponentProps<typeof Switch>, 'on'> & {on?: boolean}export function ToggleButton({ ...props }: ToggleButtonProps) {const { on, toggle } = useToggle()return <Switch {...props} on={on} onClick={toggle} />}
Quick Links
Legal Stuff
Social Media