Compound Components Pattern
Components That Work Together as One
LinkedIn Hook
Every React developer has used compound components without knowing the name. Select and Option. Tabs and Tab. Accordion and AccordionItem. These are components that only make sense together — they share state implicitly, and the parent controls the behavior while the children control the rendering.
The compound components pattern is one of the most powerful API design patterns in React. It lets you build flexible, declarative component libraries where users compose behavior from small pieces instead of passing 15 props to a single monolithic component. Every serious UI library uses it: Radix UI, Headless UI, Reach UI, Chakra UI.
Yet in interviews, most candidates cannot explain how compound components share state. They have never built a Tabs component where Tab and TabPanel communicate without explicit prop passing. They do not know the difference between React.Children.map with cloneElement versus the context-based approach — or why the context approach won.
Interviewers will ask: "Design a reusable Tabs component API. How would the Tab know which one is active? How would you share state between parent and children without forcing the user to wire everything manually?" They want to hear you talk about implicit state sharing, the Provider pattern, and why compound components produce cleaner APIs than prop-heavy alternatives.
In this lesson, I break down both approaches: the classic React.Children + cloneElement technique and the modern context-based compound components pattern. You will build real examples — Select/Option, Tabs/Tab — and understand the tradeoffs that make context the clear winner for production code.
If you have ever wondered how Radix UI and Headless UI build such clean, composable APIs — this lesson reveals the pattern behind them.
Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #DesignPatterns #CompoundComponents #WebDevelopment #CodingInterview #100DaysOfCode
What You'll Learn
- What compound components are and why they produce cleaner APIs than prop-heavy components
- How to build compound components using React.Children.map and cloneElement (classic approach)
- How to build compound components using React Context (modern approach)
- Why the context-based approach is preferred in production and library code
- How real libraries like Radix UI and Headless UI use this pattern
The Concept — Components That Belong Together
Analogy: The Orchestra
Imagine an orchestra. The conductor does not play every instrument — the conductor sets the tempo, decides which section plays when, and coordinates the overall performance. The individual musicians (violin, cello, trumpet) each handle their own part, but they all follow the conductor's lead. No musician needs to be told "the tempo is 120 BPM" through a chain of whispered messages from another musician. The conductor broadcasts it, and every musician picks it up.
That is the compound components pattern. The parent component is the conductor — it holds the shared state (which tab is active, which option is selected, whether the accordion is open). The child components are the musicians — each one renders its own piece of UI, but they all read from the conductor's state to know what to do.
A <Tabs> component does not render tabs itself. It manages which tab is active. The <Tab> children render the clickable tab headers. The <TabPanel> children render the content. They all share the active index implicitly — no one passes activeIndex and onChangeIndex through every level manually.
The alternative is a monolithic component — like having one musician play every instrument. You end up with a component that takes 15 props: tabs, activeTab, onTabChange, tabClassName, panelClassName, renderTab, renderPanel, disabledTabs, orientation... It works, but it is rigid, hard to customize, and painful to maintain.
Compound components split the responsibility. The parent owns the state. The children own the rendering. The user composes them declaratively. Everyone plays their part.
The Problem: Monolithic Components
Before we build compound components, let us see the problem they solve. Here is a monolithic Tabs component — a single component that tries to do everything.
Code Example 1: The Monolithic Approach (What We Want to Avoid)
// A monolithic Tabs component that takes everything as props
// This works, but the API is rigid and hard to customize
function MonolithicTabs({ tabs, activeIndex, onChange, tabStyle, panelStyle }) {
return (
<div>
{/* Tab headers — rendered from a data array */}
<div style={{ display: "flex", borderBottom: "2px solid #ccc" }}>
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => onChange(index)}
style={{
padding: "10px 20px",
background: activeIndex === index ? "#61dafb" : "transparent",
border: "none",
cursor: "pointer",
...tabStyle,
}}
>
{tab.label}
</button>
))}
</div>
{/* Tab panel — shows content for the active tab */}
<div style={{ padding: "20px", ...panelStyle }}>
{tabs[activeIndex].content}
</div>
</div>
);
}
// Usage — everything is configured through props
function App() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<MonolithicTabs
activeIndex={activeIndex}
onChange={setActiveIndex}
tabs={[
{ label: "Profile", content: <ProfileInfo /> },
{ label: "Settings", content: <SettingsForm /> },
{ label: "Billing", content: <BillingDetails /> },
]}
/>
);
}
// Output: Renders three tab buttons and the active panel
// Problem: What if you want an icon inside one tab? A badge on another?
// What if you want to disable one tab? Add a tooltip to another?
// You keep adding more props: disabledTabs, renderTab, tabIcons, badges...
// The API becomes a prop nightmare.
Key point: Monolithic components collect complexity in their prop signature. Compound components distribute it across composable children.
Classic Approach: React.Children + cloneElement
The original way to build compound components in React uses React.Children.map to iterate over children and React.cloneElement to inject shared props into each child. The parent secretly passes state to its children without the user knowing.
Code Example 2: Select/Option with cloneElement
import { useState, Children, cloneElement } from "react";
// The parent component — manages which option is selected
function Select({ children, defaultValue, onChange }) {
const [selectedValue, setSelectedValue] = useState(defaultValue || "");
// Handle an option being clicked
function handleSelect(value) {
setSelectedValue(value);
if (onChange) onChange(value);
}
return (
<div style={{ border: "1px solid #ccc", borderRadius: "8px", padding: "4px" }}>
{/* Iterate over each child and inject the shared state as props */}
{/* The user never passes 'selected' or 'onSelect' to Option manually */}
{Children.map(children, (child) => {
// Safety check — skip non-element children (strings, nulls)
if (!child || !child.type) return child;
// Clone the child element with additional props injected
return cloneElement(child, {
selected: child.props.value === selectedValue,
onSelect: handleSelect,
});
})}
</div>
);
}
// The child component — renders a single option
// It receives 'selected' and 'onSelect' from the parent via cloneElement
function Option({ value, children, selected, onSelect }) {
return (
<div
onClick={() => onSelect(value)}
style={{
padding: "8px 12px",
cursor: "pointer",
background: selected ? "#61dafb" : "transparent",
borderRadius: "4px",
}}
>
{/* Show a checkmark if this option is selected */}
{selected ? "✓ " : " "}
{children}
</div>
);
}
// Usage — clean, declarative API
// The user never passes 'selected' or 'onSelect' to Option
function App() {
return (
<Select defaultValue="react" onChange={(val) => console.log("Selected:", val)}>
<Option value="react">React</Option>
<Option value="vue">Vue</Option>
<Option value="angular">Angular</Option>
</Select>
);
}
// Output (initial render):
// React (highlighted in blue with checkmark)
// Vue
// Angular
// User clicks "Vue":
// React
// Vue (now highlighted in blue with checkmark)
// Angular
// Console: "Selected: vue"
How it works: Select uses Children.map to loop through its children. For each Option, it calls cloneElement to create a copy with extra props injected: selected (whether this option matches the current value) and onSelect (the callback to update the value). The user writes <Option value="react">React</Option> — simple and clean. The wiring happens behind the scenes.
The downside: This approach breaks if you wrap Option in another component or add a <div> between Select and Option. Children.map only sees direct children — it cannot reach into nested wrappers.
Modern Approach: Context-Based Compound Components
The modern approach uses React Context to share state between the parent and its children. This is more flexible because children can be nested at any depth — they just consume the context.
Code Example 3: Tabs/Tab/TabPanel with Context
import { createContext, useContext, useState } from "react";
// Create a context for sharing tab state between Tabs, Tab, and TabPanel
const TabsContext = createContext();
// Custom hook — children use this to access the shared state
// Throws an error if used outside of <Tabs>, making debugging easier
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tab/TabPanel must be used inside <Tabs>");
}
return context;
}
// Parent component — the conductor
// Manages the active index and provides it to all descendants via context
function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
// Every descendant (no matter how deeply nested) can access this value
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
// TabList — a wrapper for Tab buttons (optional, for layout)
function TabList({ children }) {
return (
<div style={{ display: "flex", borderBottom: "2px solid #333", gap: "4px" }}>
{children}
</div>
);
}
// Tab — a single clickable tab header
// Reads activeIndex from context to know if it is active
// Calls setActiveIndex from context when clicked
function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useTabsContext();
const isActive = activeIndex === index;
return (
<button
onClick={() => setActiveIndex(index)}
style={{
padding: "10px 20px",
background: isActive ? "#61dafb" : "transparent",
color: isActive ? "#0d1117" : "#ccc",
border: "none",
borderBottom: isActive ? "3px solid #ff2d55" : "3px solid transparent",
cursor: "pointer",
fontWeight: isActive ? "bold" : "normal",
}}
>
{children}
</button>
);
}
// TabPanel — renders its content only when the matching tab is active
// Reads activeIndex from context to decide whether to show or hide
function TabPanel({ index, children }) {
const { activeIndex } = useTabsContext();
// Only render when this panel's index matches the active tab
if (activeIndex !== index) return null;
return <div style={{ padding: "20px" }}>{children}</div>;
}
// Usage — fully composable, declarative API
function App() {
return (
<Tabs defaultIndex={0}>
<TabList>
<Tab index={0}>Profile</Tab>
<Tab index={1}>Settings</Tab>
<Tab index={2}>Billing</Tab>
</TabList>
<TabPanel index={0}>
<h2>Profile Information</h2>
<p>Name: Jane Developer</p>
</TabPanel>
<TabPanel index={1}>
<h2>Settings</h2>
<p>Theme: Dark Mode</p>
</TabPanel>
<TabPanel index={2}>
<h2>Billing</h2>
<p>Plan: Pro ($29/month)</p>
</TabPanel>
</Tabs>
);
}
// Output (initial render):
// [Profile] Settings Billing (Profile tab is active, highlighted)
// Profile Information
// Name: Jane Developer
// User clicks "Settings":
// Profile [Settings] Billing (Settings tab is now active)
// Settings
// Theme: Dark Mode
// User clicks "Billing":
// Profile Settings [Billing] (Billing tab is now active)
// Billing
// Plan: Pro ($29/month)
Why context wins over cloneElement:
- Nesting flexibility — Children can be wrapped in divs, fragments, or other components. Context does not care about DOM hierarchy.
- No magic prop injection — Children explicitly consume context with a hook. The data flow is traceable.
- TypeScript friendly — Context types are clean. cloneElement's injected props are invisible to the type system.
- Scalable — Adding new child components (TabPanel, TabIndicator, TabCloseButton) just means adding another
useTabsContext()call.
Code Example 4: Real-World Accordion with Context (Production-Style)
import { createContext, useContext, useState, useCallback } from "react";
// Context for sharing accordion state
const AccordionContext = createContext();
// Context for sharing individual item state
const AccordionItemContext = createContext();
// Parent — manages which items are expanded
// Supports single-expand (only one open) or multi-expand (many open)
function Accordion({ children, allowMultiple = false }) {
const [expandedItems, setExpandedItems] = useState(new Set());
// Toggle an item open or closed
const toggleItem = useCallback(
(itemId) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
// Item is open — close it
next.delete(itemId);
} else if (allowMultiple) {
// Multi mode — add this item without closing others
next.add(itemId);
} else {
// Single mode — close everything else, open this one
next.clear();
next.add(itemId);
}
return next;
});
},
[allowMultiple]
);
// Check if a specific item is expanded
const isExpanded = useCallback(
(itemId) => expandedItems.has(itemId),
[expandedItems]
);
return (
<AccordionContext.Provider value={{ toggleItem, isExpanded }}>
<div style={{ border: "1px solid #333", borderRadius: "8px" }}>
{children}
</div>
</AccordionContext.Provider>
);
}
// AccordionItem — wraps a trigger and content pair
// Provides its own ID to children so they know which item they belong to
function AccordionItem({ id, children }) {
return (
<AccordionItemContext.Provider value={{ itemId: id }}>
<div style={{ borderBottom: "1px solid #333" }}>{children}</div>
</AccordionItemContext.Provider>
);
}
// AccordionTrigger — the clickable header
function AccordionTrigger({ children }) {
const { toggleItem, isExpanded } = useContext(AccordionContext);
const { itemId } = useContext(AccordionItemContext);
const expanded = isExpanded(itemId);
return (
<button
onClick={() => toggleItem(itemId)}
style={{
width: "100%",
padding: "12px 16px",
background: "transparent",
color: "#fff",
border: "none",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
fontSize: "16px",
}}
>
{children}
{/* Rotate the arrow when expanded */}
<span style={{ transform: expanded ? "rotate(180deg)" : "rotate(0deg)" }}>
▼
</span>
</button>
);
}
// AccordionContent — the collapsible body
function AccordionContent({ children }) {
const { isExpanded } = useContext(AccordionContext);
const { itemId } = useContext(AccordionItemContext);
// Only render content when expanded
if (!isExpanded(itemId)) return null;
return (
<div style={{ padding: "0 16px 16px", color: "#aaa" }}>
{children}
</div>
);
}
// Usage — clean and composable
function FAQ() {
return (
<Accordion allowMultiple={false}>
<AccordionItem id="q1">
<AccordionTrigger>What is React?</AccordionTrigger>
<AccordionContent>
React is a JavaScript library for building user interfaces using
a component-based architecture and a virtual DOM.
</AccordionContent>
</AccordionItem>
<AccordionItem id="q2">
<AccordionTrigger>What are hooks?</AccordionTrigger>
<AccordionContent>
Hooks are functions that let you use state and lifecycle features
in function components without writing classes.
</AccordionContent>
</AccordionItem>
<AccordionItem id="q3">
<AccordionTrigger>What is the virtual DOM?</AccordionTrigger>
<AccordionContent>
The virtual DOM is an in-memory representation of the real DOM
that React uses to calculate minimal updates.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
// Output (initial — all closed):
// What is React? ▼
// What are hooks? ▼
// What is the virtual DOM? ▼
// User clicks "What are hooks?":
// What is React? ▼
// What are hooks? ▲
// Hooks are functions that let you use state and lifecycle
// features in function components without writing classes.
// What is the virtual DOM? ▼
// User clicks "What is the virtual DOM?" (single mode — previous closes):
// What is React? ▼
// What are hooks? ▼
// What is the virtual DOM? ▲
// The virtual DOM is an in-memory representation of the real
// DOM that React uses to calculate minimal updates.
Key point: Notice the two levels of context — AccordionContext for the overall state and AccordionItemContext for each item's identity. This is how production libraries like Radix UI structure compound components: nested contexts give each child exactly the information it needs.
How Real Libraries Use Compound Components
Production libraries like Radix UI and Headless UI are built entirely on the compound components pattern with context.
Radix UI (used by shadcn/ui):
// Radix Tabs — context-based compound components
<Tabs.Root defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">Profile content</Tabs.Content>
<Tabs.Content value="settings">Settings content</Tabs.Content>
</Tabs.Root>
Headless UI (from Tailwind Labs):
// Headless UI Disclosure — context-based compound components
<Disclosure>
<Disclosure.Button>Is team pricing available?</Disclosure.Button>
<Disclosure.Panel>Yes! We offer discounts for teams of 5+.</Disclosure.Panel>
</Disclosure>
Both libraries use the same core ideas: a root component manages state via context, and child components consume that context to render appropriately. They are "headless" because the compound components handle behavior and state — you bring your own styles.
Common Mistakes
Mistake 1: Using cloneElement when children are not direct descendants
// BAD: cloneElement only injects props into DIRECT children
// If you wrap Option in a div or another component, the props never arrive
function Select({ children }) {
return (
<div>
{Children.map(children, (child) =>
cloneElement(child, { selected: true }) // only works on direct children
)}
</div>
);
}
// This breaks — the div receives 'selected', not the Option inside it
<Select>
<div className="option-group">
<Option value="a">A</Option> {/* Never receives 'selected' */}
</div>
</Select>
// GOOD: Use context instead — it works at any depth
function Select({ children }) {
const [selected, setSelected] = useState("");
return (
<SelectContext.Provider value={{ selected, setSelected }}>
<div>{children}</div>
</SelectContext.Provider>
);
}
// Now this works — Option reads from context regardless of nesting
<Select>
<div className="option-group">
<Option value="a">A</Option> {/* Reads context correctly */}
</div>
</Select>
Mistake 2: Forgetting to validate context usage
// BAD: Using context without checking if the provider exists
// If someone uses Tab outside of Tabs, they get a cryptic "cannot read
// property of undefined" error instead of a helpful message
function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
// If TabsContext is undefined, this crashes with no useful error
return <button onClick={() => setActiveIndex(index)}>{children}</button>;
}
// GOOD: Create a custom hook that throws a descriptive error
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
"Tab, TabList, and TabPanel must be rendered inside <Tabs>. " +
"Make sure you are not using these components outside of a Tabs parent."
);
}
return context;
}
function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useTabsContext();
// Now a missing Tabs parent gives a clear, actionable error message
return <button onClick={() => setActiveIndex(index)}>{children}</button>;
}
Mistake 3: Exposing internal state management to the user
// BAD: Making the user manage state that the compound component should own
// This defeats the purpose — the user has to wire everything manually
<Tabs>
<Tab
isActive={activeIndex === 0}
onClick={() => setActiveIndex(0)}
>
Profile
</Tab>
<Tab
isActive={activeIndex === 1}
onClick={() => setActiveIndex(1)}
>
Settings
</Tab>
</Tabs>
// GOOD: The compound component manages state internally
// The user just declares the structure — state sharing is implicit
<Tabs defaultIndex={0}>
<TabList>
<Tab index={0}>Profile</Tab>
<Tab index={1}>Settings</Tab>
</TabList>
<TabPanel index={0}><ProfileInfo /></TabPanel>
<TabPanel index={1}><SettingsForm /></TabPanel>
</Tabs>
// Even better: auto-assign indices so the user does not pass them
// (This is what Radix UI does with value strings instead of indices)
Interview Questions
Q: What is the compound components pattern and when would you use it?
Compound components are a set of components that work together to form a complete UI element, sharing implicit state through a parent. You use this pattern when building reusable UI primitives — Tabs, Accordion, Select, Menu, Disclosure — where a single monolithic component would require too many props. The parent manages state (which tab is active, which item is expanded) and the children consume that state to render their piece. The user gets a declarative, composable API:
<Tabs><Tab /><TabPanel /></Tabs>instead of<Tabs tabs={[...]} activeTab={...} onChange={...} />. It distributes complexity across multiple small components rather than concentrating it in one.
Q: What are the two main ways to implement compound components in React? Which is better?
The two approaches are: (1)
React.Children.map+cloneElement, where the parent iterates over direct children and clones them with injected props, and (2) React Context, where the parent wraps children in a Provider and children consume shared state with useContext. The context approach is better for production code. cloneElement only works with direct children — it breaks if children are wrapped in divs, fragments, or custom components. It also injects props invisibly, which is hard to debug and TypeScript-unfriendly. Context works at any depth, is explicit (children call useContext), is type-safe, and scales when you add new child component types. Every modern component library (Radix, Headless UI) uses the context approach.
Q: How does Radix UI use the compound components pattern?
Radix UI structures every component as a set of compound parts: a Root component that manages state via context, and sub-components (Trigger, Content, Item, etc.) that consume that context. For example,
Tabs.Rootholds the active value,Tabs.Triggerreads and updates it, andTabs.Contentconditionally renders based on it. Radix uses string values instead of numeric indices, making the API more readable. The components are "headless" — they handle behavior, state, keyboard navigation, and accessibility, but render no visual styles. Users apply their own styles via className or styled-components. The compound pattern lets Radix keep each sub-component focused on one responsibility while coordinating through shared context.
Q: What are the limitations of React.Children.map and cloneElement?
First, cloneElement only injects props into direct children. If a child is wrapped in a div, fragment, or higher-order component, the injected props go to the wrapper, not the intended child. Second, the injected props are invisible in the child's type definition — TypeScript cannot see them, leading to type errors or
anycasts. Third, React.Children.map does not handle all child types gracefully (strings, numbers, Fragments with nested children). Fourth, the React team has signaled that cloneElement is a legacy pattern and recommends context or render props instead. Fifth, it tightly couples parent and child — the parent must know the exact props its children accept, making the components less independently reusable.
Q: How would you design a compound component that supports both controlled and uncontrolled usage?
Support both by checking whether the user passed a
valueprop (controlled) or not (uncontrolled). IfvalueandonChangeare provided, use them directly — the parent controls the state. If onlydefaultValueis provided (or nothing), manage state internally with useState. This pattern looks like:const [internalValue, setInternalValue] = useState(defaultValue)andconst isControlled = value !== undefined. The context providesisControlled ? value : internalValueas the current value. On change, callsetInternalValuefor uncontrolled mode and always call theonChangecallback if provided. Radix UI uses exactly this pattern — every compound root component accepts both controlled (value+onValueChange) and uncontrolled (defaultValue) props.
Quick Reference — Cheat Sheet
COMPOUND COMPONENTS PATTERN
==============================
What:
Components that work together, sharing implicit state
Parent manages state, children render UI pieces
Examples: Tabs+Tab, Select+Option, Accordion+AccordionItem
Why (vs monolithic):
Monolithic: <Tabs tabs={[...]} activeTab={0} onChange={fn} /> // 15 props
Compound: <Tabs><Tab>A</Tab><Tab>B</Tab><TabPanel>...</TabPanel></Tabs>
Benefit: Composable, customizable, clean API, separation of concerns
Approach 1 — cloneElement (Classic, avoid in new code):
function Parent({ children }) {
return Children.map(children, child =>
cloneElement(child, { sharedProp: value })
);
}
Limitation: Only works with direct children
Limitation: Invisible prop injection, TypeScript-unfriendly
Limitation: Breaks with wrappers, fragments, HOCs
Approach 2 — Context (Modern, preferred):
const Ctx = createContext();
function Parent({ children }) {
const [state, setState] = useState(initial);
return (
<Ctx.Provider value={{ state, setState }}>
{children}
</Ctx.Provider>
);
}
function Child() {
const { state, setState } = useContext(Ctx);
return <div>{state}</div>;
}
Benefit: Works at any nesting depth
Benefit: Explicit data flow (useContext call is visible)
Benefit: TypeScript-friendly, scalable
Always add a guard hook:
function useMyContext() {
const ctx = useContext(MyContext);
if (!ctx) throw new Error("Component must be inside <Parent>");
return ctx;
}
+----------------------------+----------------------------------+
| cloneElement | Context |
+----------------------------+----------------------------------+
| Direct children only | Any depth |
| Invisible prop injection | Explicit useContext call |
| TypeScript-unfriendly | Full type safety |
| Breaks with wrappers | Works with any wrapper |
| Legacy approach | Used by Radix, Headless UI |
+----------------------------+----------------------------------+
Library Examples:
Radix UI: Tabs.Root > Tabs.List > Tabs.Trigger + Tabs.Content
Headless UI: Disclosure > Disclosure.Button + Disclosure.Panel
Reach UI: Tabs > TabList > Tab + TabPanels > TabPanel
Pattern: Root (state) > Sub-components (consume context)
Design Tips:
- Use string values (not indices) for better readability
- Support controlled (value+onChange) AND uncontrolled (defaultValue)
- Namespace sub-components: Tabs.List, Tabs.Trigger (dot notation)
- Keep compound components headless — let users style them
- Add ARIA attributes for accessibility in each sub-component
Previous: Lesson 9.2 — Render Props Pattern -> Next: Lesson 9.4 — Error Boundaries ->
This is Lesson 9.3 of the React Interview Prep Course — 10 chapters, 42 lessons.