Compound components

Parts share state via a parent Context, with no prop drilling.

From frontend-patterns, section “Compound Components”.

Who carries the active tab

Click a tab, then raise the depth.

Prop drillingBad
12 props
ContextGood
0 props
const TabsContext = createContext<TabsContextValue | undefined>(undefined)

export function Tabs({ children, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab)
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}

A Tab outside Tabs

Drag Billing out by its handle, then click it.

Props, no guardBad
Context with a guardGood
export function Tab({ id, children }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab must be used within Tabs')
  return <button onClick={() => context.setActiveTab(id)}>{children}</button>
}

Add a tab

Type an id, press Enter, compare the JSX.

Prop drillingBad
8 props
Compound componentsGood
0 props

Why it matters, pros and cons

Why it matters

Parts share state via context. A Tab used alone throws instead of failing quietly.

Pros

  • New layers or tabs add no wiring.
  • Misuse throws on first render.
  • Call site reads like the markup.

Cons

  • Provider and readers need "use client".
  • Every reader re-renders when the value changes.
  • One parent, one child: plain props are simpler.