Compound Components
Build flexible, shared-state components with the compound components pattern
What you'll learn
Explain the compound component pattern and when to use it
Share implicit state between parent and child components with Context
Build a Tabs component from scratch
Compare compound components, render props, and hooks approaches
What Are Compound Components?
Compound components are a group of components that work together to form a cohesive UI, sharing implicit state without passing props manually through every level.
Think <select> and <option> in HTML — they share state (selected value) implicitly via parent-child relationship. React compound components mimic this using Context.
Example: Built-in Tabs API
// The API you probably already use:
<Tabs>
<TabsList>
<TabsTrigger value="a">Tab A</TabsTrigger>
<TabsTrigger value="b">Tab B</TabsTrigger>
</TabsList>
<TabsContent value="a">Content A</TabsContent>
<TabsContent value="b">Content B</TabsContent>
</Tabs>
No prop drilling. No wiring by the consumer. That's compound components.
Building Tabs from Scratch
Step 1: Context for shared state
"use client"
import { createContext, useContext, useState, ReactNode } from "react"
interface TabsContextValue {
active: string
setActive: (value: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabsContext() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error("Tabs compound components must be inside <Tabs>")
return ctx
}
Step 2: The parent provider
interface TabsProps {
defaultValue: string
children: ReactNode
}
function Tabs({ defaultValue, children }: TabsProps) {
const [active, setActive] = useState(defaultValue)
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
)
}
Step 3: Child components
function TabsList({ children }: { children: ReactNode }) {
return <div className="flex gap-1 border-b">{children}</div>
}
interface TabProps {
value: string
children: ReactNode
}
function Tab({ value, children }: TabProps) {
const { active, setActive } = useTabsContext()
const activeClass =
active === value ? "bg-blue-500 text-white" : "bg-gray-100"
return (
<button
className={`rounded-t px-4 py-2 ${activeClass}`}
onClick={() => setActive(value)}
>
{children}
</button>
)
}
function TabPanel({ value, children }: TabProps) {
const { active } = useTabsContext()
if (active !== value) return null
return <div className="p-4">{children}</div>
}
Usage
<Tabs defaultValue="code">
<TabsList>
<Tab value="code">Code</Tab>
<Tab value="preview">Preview</Tab>
</TabsList>
<TabPanel value="code">```tsx console.log("hi")```</TabPanel>
<TabPanel value="preview">// rendered output</TabPanel>
</Tabs>
API Design Comparison
See It In Action: Accordion
Build an Accordion with Compound Components
Code
tsx
1import { createContext, useContext, useState, type ReactNode } from "react"2 3const AccordionContext = createContext<{4openItem: string | null5toggle: (id: string) => void6} | null>(null)7 8function Accordion({ children }: { children: ReactNode }) {9const [openItem, setOpenItem] = useState<string | null>(null)10return (11 12<AccordionContext.Provider value={{ openItem, toggle: (id) => setOpenItem(prev => prev === id ? null : id) }}>13{children}14</AccordionContext.Provider>15)16}17function AccordionItem({ id, children }: { id: string; children: ReactNode }) {18return <div>{children}</div>19}20function AccordionTrigger({ id, children }: { id: string; children: ReactNode }) {21const ctx = useContext(AccordionContext)!22return <button onClick={() => ctx.toggle(id)}>{children}</button>23}24function AccordionPanel({ id, children }: { id: string; children: ReactNode }) {25const ctx = useContext(AccordionContext)!26return ctx.openItem === id ? <div>{children}</div> : null27}28 29// Usage:30// <Accordion>31// <AccordionItem id="1">32// <AccordionTrigger id="1">Section 1</AccordionTrigger>33// <AccordionPanel id="1">Content 1</AccordionPanel>34// </AccordionItem>35// </Accordion>Compound components share implicit state via Context. The `<Accordion>`provider manages which item is open; children call`toggle()`via`useContext`. This pattern creates a declarative, flexible API.
Key Takeaways
Compound components share implicit state via React Context
The parent component is the Context provider; children consume via useContext
Always validate context exists and throw a descriptive error for misuse
Compound components trade flexibility for ergonomics — choose based on API goals
Real-world examples: Tabs, Select, Menu, Accordion, Stepper