Compound Components
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐งฑ LEGO vs pre-assembled toy โ compound components give you the pieces that are guaranteed to fit.
Pre-assembled toy (monolithic)
You get a fully formed component. It does what the author imagined. Need a slightly different shape? Pass more props. Eventually you have 40 props, a renderItem, a renderFooter, a customClassNameโฆ and it still doesn't do exactly what you want.
LEGO kit (compound)
You get typed pieces. Each piece knows how to connect to the others because they share a private channel (React Context). You assemble exactly the shape you need. The pieces guarantee correctness โ you can't wire them wrong.
The three patterns for sharing logic
Render Props (legacy)
| 1 | <DataProvider render={(data) => <Chart data={data} />} /> |
Passes data via a function prop. Composable but creates callback hell and is hard to read.
HOC โ Higher-Order Component (legacy)
| 1 | const ConnectedChart = withData(Chart); |
Wraps a component to inject props. Hidden data flow, naming collisions, hard to debug.
Compound + Context (modern)
| 1 | <Select><Select.Trigger /><Select.Content /></Select> |
Context is the private channel. Sub-components read state without prop drilling. Readable, composable, typed.
Interactive Sandbox
โ Move something, see it react instantly.Component
| 1 | <Accordion |
| 2 | items={[ |
| 3 | { id: "a", title: "Section A", content: "..." }, |
| 4 | { id: "b", title: "Section B", content: "..." }, |
| 5 | ]} |
| 6 | onExpand={(id) => setOpen(id)} |
| 7 | defaultOpen="a" |
| 8 | allowMultiple={false} |
| 9 | /> |
How Context wires it
Root creates a Context with { openValue, setOpenValue }. Each Accordion.Item reads context to know if it's open. Trigger toggles the value. Content conditionally renders.
Context tree
Challenge
Explore all 5 patterns in compound mode. Notice how the API surface shrinks while flexibility grows.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: How does React Context enable compound components?
| 1 | // 1. Root creates and provides context |
| 2 | const TabsContext = createContext<TabsCtx | null>(null); |
| 3 | ย |
| 4 | function Tabs({ defaultValue, children }) { |
| 5 | const [active, setActive] = useState(defaultValue); |
| 6 | return ( |
| 7 | <TabsContext.Provider value={{ active, setActive }}> |
| 8 | {children} |
| 9 | </TabsContext.Provider> |
| 10 | ); |
| 11 | } |
| 12 | ย |
| 13 | // 2. Sub-components consume context โ no prop drilling |
| 14 | function Tab({ value, children }) { |
| 15 | const { active, setActive } = useContext(TabsContext)!; |
| 16 | return ( |
| 17 | <button |
| 18 | role="tab" |
| 19 | aria-selected={active === value} |
| 20 | onClick={() => setActive(value)} |
| 21 | > |
| 22 | {children} |
| 23 | </button> |
| 24 | ); |
| 25 | } |
Q: Render props vs compound components โ when does each make sense?
Render props shine for sharing logic without UI โ a data-fetching hook, a virtualizer, a drag-and-drop context. The parent owns rendering entirely. Compound components shine when the API maps to known HTML semantics (tabs, select, accordion) โ the sub-components enforce structure and wire ARIA automatically. Use render props when the consumer needs full rendering control; use compounds when the structure itself has semantic meaning.
Q: When is a monolithic API better?
Monolithic APIs win when the component is truly atomic and variation is trivial: a Button with variant="primary|secondary", a Badge with status="success|error". The breakpoint: if you find yourself adding renderX props or making the component responsible for layout decisions, reach for compound instead.
Bug: Compound components don't work across Portals
| 1 | // โ BUG: Select.Content in a Portal โ outside Provider |
| 2 | <Select> {/* Provider lives here */} |
| 3 | <Select.Trigger /> |
| 4 | {createPortal( |
| 5 | <Select.Content />, {/* null context! */} |
| 6 | document.body |
| 7 | )} |
| 8 | </Select> |
| 9 | ย |
| 10 | // โ FIX: pass context value through the portal manually |
| 11 | const ctx = useContext(SelectContext); |
| 12 | {createPortal( |
| 13 | <SelectContext.Provider value={ctx}> |
| 14 | <Select.Content /> |
| 15 | </SelectContext.Provider>, |
| 16 | document.body |
| 17 | )} |
The Deep Dive
โ Spec refs, engine internals, the minutiae.React.cloneElement approach (legacy)
Before Context, compound components used React.cloneElement to inject props into children. The parent called React.Children.mapand cloned each child with extra props. It's fragile โ only works with direct children, breaks with fragments and conditional rendering, and TypeScript types are difficult.
| 1 | // Old pattern โ avoid |
| 2 | function Tabs({ children, active }) { |
| 3 | return React.Children.map(children, (child) => |
| 4 | React.cloneElement(child, { active }) // inject active prop |
| 5 | ); |
| 6 | } |
| 7 | // Breaks: <>{children}</> wraps in fragment, map doesn't see tabs |
| 8 | // Breaks: conditional children skip the clone |
Radix UI primitives โ compound done right
Radix implements every primitive as compound components with full ARIA support, keyboard navigation, focus management, and screen reader announcements. The Slot pattern (asChild) lets you merge props onto a custom element without a wrapper div.
| 1 | import * as Select from '@radix-ui/react-select'; |
| 2 | ย |
| 3 | <Select.Root value={val} onValueChange={setVal}> |
| 4 | <Select.Trigger asChild> |
| 5 | <button className="my-custom-button"> |
| 6 | <Select.Value /> {/* renders current value */} |
| 7 | </button> |
| 8 | </Select.Trigger> |
| 9 | <Select.Portal> {/* renders in document.body */} |
| 10 | <Select.Content> |
| 11 | <Select.Viewport> |
| 12 | <Select.Item value="apple">Apple</Select.Item> |
| 13 | <Select.Item value="banana">Banana</Select.Item> |
| 14 | </Select.Viewport> |
| 15 | </Select.Content> |
| 16 | </Select.Portal> |
| 17 | </Select.Root> |
TypeScript: typing compound component exports
| 1 | // Pattern: attach sub-components as properties |
| 2 | interface TabsComponent { |
| 3 | (props: TabsProps): JSX.Element; |
| 4 | List: typeof TabsList; |
| 5 | Tab: typeof TabsTab; |
| 6 | Panel: typeof TabsPanel; |
| 7 | } |
| 8 | ย |
| 9 | const Tabs = forwardRef<HTMLDivElement, TabsProps>( |
| 10 | (props, ref) => { /* ... */ } |
| 11 | ) as TabsComponent; |
| 12 | ย |
| 13 | Tabs.List = TabsList; |
| 14 | Tabs.Tab = TabsTab; |
| 15 | Tabs.Panel = TabsPanel; |
| 16 | ย |
| 17 | // Usage โ full autocomplete |
| 18 | <Tabs.List> |
| 19 | <Tabs.Tab value="a">Tab A</Tabs.Tab> |
| 20 | </Tabs.List> |
react-aria for headless accessibility
Adobe's react-aria provides hooks for every interactive widget (useSelect, useTabs, useMenu) that return the exact ARIA attributes, keyboard handlers, and focus managers you need. You bring the compound structure; react-aria brings the behavior.
| 1 | import { useTabList, useTab, useTabPanel } from '@react-aria/tabs'; |
| 2 | import { useTabListState } from '@react-stately/tabs'; |
| 3 | ย |
| 4 | function Tabs({ children, ...props }) { |
| 5 | const state = useTabListState(props); |
| 6 | const ref = useRef(null); |
| 7 | const { tabListProps } = useTabList(props, state, ref); |
| 8 | ย |
| 9 | return ( |
| 10 | <div> |
| 11 | <div {...tabListProps} ref={ref}> |
| 12 | {[...state.collection].map((item) => ( |
| 13 | <Tab key={item.key} item={item} state={state} /> |
| 14 | ))} |
| 15 | </div> |
| 16 | <TabPanel key={state.selectedItem?.key} state={state} /> |
| 17 | </div> |
| 18 | ); |
| 19 | } |
Interview Questions
โ Real questions from real interviews โ with answers.Use compound components with Context; sub-components map to ARIA roles.
Switch when you need renderX props or custom markup between sub-parts.
Only the focused item has tabIndex=0; all others are -1. Focus moves by updating which item gets 0.
The Portal child is outside the Provider tree and gets null context.
Form creates a context; Field creates a child context with a generated field ID; Label and Input read that ID.
Separate headless behavior from styled presentation; export both layers independently.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.In a compound Menu, what ARIA attribute on Menu.Content communicates to screen readers that it is a menu widget?