Compound Components Pattern
Reviewed & published by Brayan K
Compound components are a pattern where several related components work together as one cohesive unit, sharing state implicitly through a common context instead of through props passed at every level — like <Tabs> with its <Tab> and <TabPanel> children. The result is a flexible, declarative API for the people who use your component.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- What compound components are and when to reach for them
- Sharing implicit state through a context provider
- Building <Tabs> with cooperating sub-components
- Reading shared state with useContext (no prop drilling)
- Giving consumers a flexible, declarative API
- Guarding against misuse outside the parent
1️⃣ The Shared State Is the Whole Idea
A compound component centers on one piece of shared state — for tabs, "which tab is active." Each sub-component reads that state to render itself and calls a setter to change it. Crucially, the children don't receive this through props; they reach into a shared context, so consumers can nest and arrange them freely.
// The API consumers WANT to write — clean and declarative:
<Tabs defaultTab="home">
<Tab id="home">Home</Tab>
<Tab id="profile">Profile</Tab>
<TabPanel id="home">Welcome!</TabPanel>
<TabPanel id="profile">Your profile</TabPanel>
</Tabs>// The heart of the pattern: one shared piece of state (the active tab)
// that several sub-components read and update. We model the context
// as a plain object the "components" all reach into.
const tabsContext = { active: "home", setActive(id) { this.active = id; } };
function Tab(id) { // a sub-component
const isActive = tabsContext.active === id;
return id + (isActive ? " (active)" : "");
}
console.log(Tab("home")); // home (active)
console.log(Tab("profile")); // profile
tabsContext.setActive("profile"); // a click happens
console.log(Tab("home")); // home
console.log(Tab("profile")); // profile (active)2️⃣ Build It With a Context Provider
The parent <Tabs> owns the state and wraps its children in a Provider. Sub-components call useContext to read the active id and the setter. No props are threaded through intermediate elements — that's the "implicit" sharing.
const TabsCtx = createContext(null);
function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
return (
<TabsCtx.Provider value={{ active, setActive }}>
{children}
</TabsCtx.Provider>
);
}
function Tab({ id, children }) {
const { active, setActive } = useContext(TabsCtx);
return (
<button aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
);
}// The parent provides the shared state; sub-components consume it.
// We mock createContext / useContext to show the wiring.
function createContext(initial) { return { value: initial }; }
function useContext(ctx) { return ctx.value; }
const TabsCtx = createContext(null);
// <Tabs> sets up the shared value:
TabsCtx.value = { active: "billing", setActive(id) { TabsCtx.value.active = id; } };
// <Tab> reads it via context — no props drilled in:
function Tab(id) {
const ctx = useContext(TabsCtx);
return id + (ctx.active === id ? " ✅" : "");
}
console.log(Tab("billing")); // billing ✅
console.log(Tab("account")); // account3️⃣ The Panel and Misuse Guards
A <TabPanel> renders its children only when its id matches the active tab. To keep the API safe, default the context to null and throw a clear error if a sub-component is used outside <Tabs>, so mistakes fail loudly.
function useTabs() {
const ctx = useContext(TabsCtx);
if (ctx === null) throw new Error("Tab must be used inside <Tabs>");
return ctx;
}
function TabPanel({ id, children }) {
const { active } = useTabs();
return active === id ? <div>{children}</div> : null;
}// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// A <Tab> should render active only when ctx.active equals its id,
// and clicking it should call ctx.setActive(id).
const ctx = { active: "one", setActive(id) { ctx.active = id; } };
function Tab(id) {
const isActive = ctx.active ___ id; // 👉 use === (strict equal)
return id + (isActive ? " *" : "");
}
function clickTab(id) {
ctx.___(id); // 👉 call setActive
}
console.log(Tab("one")); // one *
console.log(Tab("two")); // two
clickTab("two");
console.log(Tab("one")); // one
console.log(Tab("two")); // two *
// ✅ Expected output:
// one *
// two
// one
// two *🧩 Reorder Challenge
These lines build the Tabs parent that provides shared state. Put them in the right order:
A. const [active, setActive] = useState(defaultTab);
B. function Tabs({ defaultTab, children }) {
C. const TabsCtx = createContext(null);
D. return <TabsCtx.Provider value={{ active, setActive }}>
E. {children}</TabsCtx.Provider>;Common Errors (and the fix)
- "Cannot read properties of null". — A sub-component was rendered outside the parent Provider. Add a useTabs guard that throws a clear message.
- New value object every render. — All consumers re-render unnecessarily. Memoize the context value with useMemo.
- Drilling props anyway. — If you're passing active down by hand, you've skipped the point. Read it from context in each sub-component.
- One giant component with config props. — Rigid and unreadable. Split into cooperating sub-components sharing context.
📋 Quick Reference
| Piece | Role |
|---|---|
| createContext(null) | Shared channel for sub-components |
| <Ctx.Provider value={x}> | Parent supplies the shared state |
| useContext(Ctx) | Sub-component reads the state |
| throw if null | Guard against misuse outside parent |
🧠 Quick Recall
1. How do sub-components share state without prop drilling?
Through a context: the parent renders a Provider, and each sub-component reads the value with useContext.
2. What's the benefit over one big config-prop component?
A flexible, declarative API: consumers arrange the sub-components freely and React wires up the shared behavior.
3. How do you stop a sub-component being used outside the parent?
Default the context to null and throw a clear error in the consumer hook when it's null.
Mini-Challenge: A Tiny Accordion
Model an accordion where only one panel is open, sharing the open id through a context object the panels read. Run it and check your output.
// 🎯 MINI-CHALLENGE: a tiny <Accordion> compound API
//
// Model an accordion where only ONE panel is open at a time.
// Build a context object with openId and setOpen(id), and a
// Panel(id) function that returns id + " [open]" when it's the
// open one, else id + " [closed]". Then open "faq" and log two panels.
//
// ✅ Expected output:
// intro [closed]
// faq [open]
const ctx = { openId: null, setOpen(id) { ctx.openId = id; } };
// your code here🎉 Lesson Complete
- ✅ Compound components cooperate as one unit via shared state
- ✅ State is shared implicitly through context, not prop drilling
- ✅ The parent renders a Provider; sub-components useContext
- ✅ Guard with a null context + thrown error for misuse
Practice quiz
What defines a set of 'compound components'?
- Components that all live in one file
- Components that take no props
- Separate components designed to work together as one unit, sharing state implicitly
- Components rendered with portals
Answer: Separate components designed to work together as one unit, sharing state implicitly. Like <Tabs>, <Tab>, <TabPanel>, each is its own component but they cooperate through shared state.
How do compound sub-components share state without prop drilling?
- The parent provides a context and each sub-component reads it with useContext
- Through global variables
- By passing props through every level
- Through localStorage
Answer: The parent provides a context and each sub-component reads it with useContext. The parent renders a context Provider; sub-components call useContext to read or update the shared state.
Where does the shared state (e.g. which tab is active) live?
- In each individual sub-component
- In a separate Redux store only
- In the DOM
- In the parent component that renders the Provider
Answer: In the parent component that renders the Provider. The parent owns the state with useState and supplies it via the Provider's value.
What goes on the context's Provider so children can read it?
- A children prop
- A value prop holding the shared state
- A ref prop
- A key prop
Answer: A value prop holding the shared state. <Ctx.Provider value={{ active, setActive }}> supplies the shared state to all consumers below it.
How do you stop a sub-component being used outside its parent?
- Default the context to null and throw a clear error if useContext returns null
- Wrap it in try/catch
- Use a default prop
- Disable the component in production
Answer: Default the context to null and throw a clear error if useContext returns null. A guard hook throws a helpful error when context is null, so <Tab> used outside <Tabs> fails loudly.
Why is the compound pattern better than one big config-prop component?
- It runs without React
- It avoids using context
- It gives consumers a flexible, declarative API they arrange freely
- It removes the need for keys
Answer: It gives consumers a flexible, declarative API they arrange freely. Consumers nest sub-components however they like and React wires up the shared behavior — far more flexible.
What does a <TabPanel> typically do?
- Always render its children
- Render its children only when its id matches the active tab
- Set the active tab on mount
- Render the parent again
Answer: Render its children only when its id matches the active tab. TabPanel reads the active id from context and renders its children only when active === id, else null.
A new value object is created on every parent render. What problem can that cause?
- The app crashes
- The context becomes null
- Children lose their keys
- All context consumers re-render unnecessarily
Answer: All context consumers re-render unnecessarily. A fresh value each render makes every consumer re-render; memoize the value with useMemo to avoid it.
Which hook does a sub-component call to read the shared state?
- useState
- useContext
- useRef
- useReducer
Answer: useContext. Sub-components call useContext(TabsCtx) to read the active id and setter the parent provided.
Where should createContext(null) be called for a compound component?
- Inside each sub-component's render
- Inside an effect
- Once at module scope so all sub-components share the same context
- On every click
Answer: Once at module scope so all sub-components share the same context. Create the context once at module scope so every sub-component references the same shared channel.
Continue this course
- Previous: forwardRef & useImperativeHandle
- Next: Render Props Pattern — Share logic with a function-as-child prop — and when custom hooks replace it
- Quick reference: React cheat sheet
Frequently asked questions
What makes a set of components 'compound'?
They're separate components designed to work together as one unit, sharing state implicitly through a shared context rather than through props passed at every level. Think <Tabs>, <TabList>, <Tab>, <TabPanel> — each is its own component but they cooperate.
How is implicit state shared without prop drilling?
The parent component creates a context with the shared state (e.g. the active tab) and renders a Provider around its children. Each sub-component calls useContext to read or update that state, so you never thread props through every level.
Why is this better than one big component with lots of props?
It gives consumers a flexible, declarative API. They arrange the sub-components however they like and React wires up the shared behavior. A single mega-component with dozens of config props is rigid and hard to read by comparison.
How do I stop sub-components being used outside the parent?
Have the context default to null and throw a clear error in the hook if useContext returns null. Then <Tab> used outside <Tabs> fails loudly with a helpful message instead of behaving strangely.