If you've ever bought a piece of furniture from a certain Swedish store, you know the drill. You don't buy a pre-assembled "Sitting Unit." You buy a frame, cushions, legs, and maybe a weird little wrench, and you put it together yourself.
Compound Components are the Ikea furniture of React.
Instead of building one giant, monolithic component that takes 500 props (), you build a set of smaller components that work together like magic. They share state behind the scenes, but you decide where everything goes.
Let's dive into the pattern that makes libraries like Radix UI and Headless UI possible.
π¨ The Graphical Explanation
Imagine you are building a Tabs component.
The "Mega-Prop" Way (The Old Way) π¦
You pass a giant configuration object. The component decides the layout. You have zero control. If you want to move the "Tab List" below the "Panels," you have to rewrite the library.
+-----------------------------+
| <Tabs config={...} /> |
+-----------------------------+
| [Tab 1] [Tab 2] [Tab 3] | <-- Hardcoded position
| |
| Content for Tab 1... |
+-----------------------------+
The Compound Component Way (The Flexible Way) π€Έ
You export a parent (Tabs) and several children (Tab, Panel). The Parent holds the state (which tab is active), but the User decides the layout.
<Tabs>
<TabList> <-- You put this where YOU want
<Tab>One</Tab>
<Tab>Two</Tab>
</TabList>
<TabPanels>
<Panel>Content 1</Panel>
<Panel>Content 2</Panel>
</TabPanels>
</Tabs>
The Parent (Tabs) secretly passes state to the children using Context. They communicate telepathically. π§ β¨
π» The Code: From "Prop Hell" to "Component Heaven"
Let's build a simple Accordion (toggle sections).
1. The Setup (Creating the Context)
First, we need a secret channel for our components to talk.
import React, { createContext, useContext, useState } from 'react';
// 1. Create the Context
const AccordionContext = createContext();
// 2. Create the Parent Component
const Accordion = ({ children }) => {
const [openIndex, setOpenIndex] = useState(0);
// The toggle function logic
const toggleIndex = (index) => {
setOpenIndex(prev => (prev === index ? null : index));
};
return (
<AccordionContext.Provider value={{ openIndex, toggleIndex }}>
<div className="accordion-wrapper">
{children}
</div>
</AccordionContext.Provider>
);
};
2. The Children (Consuming the Context)
Now we create the sub-components. Notice they don't accept isOpen or onClick as props from the user. They grab it from the Context!
// 3. The Item Component (Just a wrapper, usually)
const AccordionItem = ({ children }) => {
return <div className="accordion-item">{children}</div>;
};
// 4. The Trigger (The clickable part)
const AccordionHeader = ({ children, index }) => {
const { toggleIndex, openIndex } = useContext(AccordionContext);
const isActive = openIndex === index;
return (
<button
className={`accordion-header ${isActive ? 'active' : ''}`}
onClick={() => toggleIndex(index)}
>
{children} {isActive ? 'π½' : 'βΆοΈ'}
</button>
);
};
// 5. The Content (The hidden part)
const AccordionPanel = ({ children, index }) => {
const { openIndex } = useContext(AccordionContext);
if (openIndex !== index) return null;
return <div className="accordion-panel">{children}</div>;
};
// Attach sub-components to the Parent for cleaner imports (Optional but cool)
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;
export default Accordion;
3. Usage (The Magic Moment) πͺ
Look how clean this API is. No complex config objects. Just JSX.
import Accordion from './Accordion';
const FAQ = () => (
<Accordion>
<Accordion.Item>
<Accordion.Header index={0}>Is React hard?</Accordion.Header>
<Accordion.Panel index={0}>
Only until you understand `useEffect`. Then it's just chaotic.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Header index={1}>Why use Compound Components?</Accordion.Header>
<Accordion.Panel index={1}>
Because passing 50 props is bad for your blood pressure.
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
π Why Should You Do This? (Use Cases)
1. UI Libraries (Tabs, Selects, Menus)
If you are building a design system, this is mandatory. Users will want to put an icon inside the Tab, or move the Label below the Input. Compound components let them do that without bugging you to add a renderLabelBottom prop.
2. Implicit State Sharing
Notice how Accordion.Header automatically knew how to toggle the panel? The user didn't have to wire up onClick={() => setIndex(1)} manually. It "Just Works".
3. Semantic Structure
It reads like HTML. <List><Item /><Item /></List>. Itβs declarative and beautiful.
π³οΈ The Pitfalls (The "Gotchas")
1. The "Single Child" Restriction
If you try to wrap a child in a <div> that blocks the Context (rare with Context API, but common with the older React.Children.map approach), things break.
Fix: Stick to the Context API pattern shown above. It penetrates through divs like X-rays.
2. Over-Engineering
Don't use this for a simple Button. If a component doesn't have internal state that needs to be shared among multiple distinct children, you probably just need regular props.
3. Name Pollution
If you export Accordion, AccordionItem, AccordionHeader, AccordionPanel, your imports get messy.
Fix: Attach them to the main component: Accordion.Item. It keeps the namespace clean and makes you look like a pro.
π Conclusion
The Compound Component Pattern is the difference between giving someone a fish (a rigid component) and teaching them to fish (a flexible set of tools).
It requires a bit more setup code for you (the library author), but it creates a delightful experience for the developer using your component. And isn't that what we all want? To be loved by other developers? (Please validate me). π₯Ίπ€
Top comments (0)