Compound Components Pattern in React
Build flexible React APIs with compound components — fix prop soup, share state via Context, dot notation, Modal and Accordion examples, design-system use cases, pitfalls, and Card/Tab practical tasks.
Introduction
The compound components pattern lets consumers compose UI from small, named pieces — Modal.Header, Tabs.Panel, Accordion.Item — while shared state and behavior live in a parent container. Instead of one mega-component with dozens of boolean props, you get Lego blocks: flexible layout, clear responsibilities, and APIs that scale in design systems.
This guide covers:
- The problem — prop soup and rigid monoliths
- The solution — compositional, dumb sub-components
- Core implementation — Context, dot notation, children
- Examples — Modal shell and Accordion expand/collapse
- Common use cases — tabs, dropdowns, tables, libraries
- Pitfalls — over-engineering and broken composition
- Practical tasks — full Card and Tabs implementations
Quick index
| # | Section |
|---|---|
| 1 | The problem: prop soup |
| 2 | The solution: Lego blocks |
| 3 | Core implementation |
| 4 | Example: Modal |
| 5 | Example: Accordion |
| 6 | Common use cases |
| 7 | Practical task: Card |
| 8 | Practical task: Tabs |
| 9 | Pitfalls & anti-patterns |
1. The problem: prop soup
Before compound components, feature-rich widgets often collapse into a single export with endless configuration props.
Before — bloated monolith
// ❌ Rigid API — layout locked, props explode
<Modal
isOpen={open}
onClose={close}
title="Delete account"
showFooter
footerAlign="right"
primaryLabel="Delete"
secondaryLabel="Cancel"
onPrimary={handleDelete}
onSecondary={close}
hideCloseButton={false}
size="md"
body={<p>This cannot be undone.</p>}
/>| Prop soup symptom | What breaks |
|---|---|
| Bloated components | One file owns shell, header, body, footer, and actions |
| Rigid structure | footerAlign, showFooter — layout locked by author |
| Lack of flexibility | Cannot insert a checkbox between body and footer |
| Mixed responsibilities | Fetch logic, ARIA, and styling intertwined |
| Hard to test and scale | Every new layout needs a new prop |
2. The solution: Lego blocks
Compound components invert the API: the parent provides behavior and shared state; children are dumb presentation pieces the consumer arranges.
<Tabs> ← container: owns active index
<Tabs.List> ← layout region
<Tabs.Tab /> ← dumb: reads context, fires setActive
</Tabs.List>
<Tabs.Panel /> ← dumb: shows/hides based on context
</Tabs>| Principle | Meaning |
|---|---|
| Flexible building blocks | Consumers pick which sub-parts to render |
| Compositional design | Complex UI = tree of small components |
| Separation of concerns | Parent = state + context; children = markup |
| Dumb sub-components | No fetch, no business rules — context + props only |
3. Core implementation
Every compound family follows the same recipe.
| Step | Implementation |
|---|---|
| 1. Parent container | Holds state (open, activeId, expandedIndex) |
| 2. Context | Shares state + setters to descendants |
| 3. Sub-components | Read context via custom hook (useModal, useTabs) |
| 4. Dot notation | Attach subs to parent: Modal.Header = Header |
| 5. Single default export | Export Modal with all subs attached |
Minimal skeleton
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
// 1. Context + hook with guard
type ToggleContextValue = {
open: boolean
toggle: () => void
}
const ToggleContext = createContext<ToggleContextValue | null>(null)
function useToggle() {
const ctx = useContext(ToggleContext)
if (!ctx) throw new Error('Toggle sub-components must be used within <Toggle>')
return ctx
}
// 2. Parent container
function Toggle({ defaultOpen = false, children }: { defaultOpen?: boolean; children: ReactNode }) {
const [open, setOpen] = useState(defaultOpen)
const value = useMemo(() => ({ open, toggle: () => setOpen((o) => !o) }), [open])
return <ToggleContext.Provider value={value}>{children}</ToggleContext.Provider>
}
// 3. Dumb sub-components
function ToggleButton({ children }: { children: ReactNode }) {
const { toggle, open } = useToggle()
return (
<button type="button" aria-expanded={open} onClick={toggle}>
{children}
</button>
)
}
function TogglePanel({ children }: { children: ReactNode }) {
const { open } = useToggle()
if (!open) return null
return <div role="region">{children}</div>
}
// 4. Dot notation + 5. single export
Toggle.Button = ToggleButton
Toggle.Panel = TogglePanel
export { Toggle }Context vs cloneElement (legacy)
| Approach | Pros | Cons |
|---|---|---|
| Context (modern) | Any depth, TypeScript-friendly, explicit hook | Slightly more boilerplate |
cloneElement (legacy) | No Context provider | Fragile, breaks with wrappers, hard to type |
4. Example: Modal
Modal demonstrates shell behavior — open/close state shared across header, body, and footer regions.
'use client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
type ModalContextValue = {
open: boolean
onClose: () => void
}
const ModalContext = createContext<ModalContextValue | null>(null)
function useModal() {
const ctx = useContext(ModalContext)
if (!ctx) throw new Error('Modal.* must be used within <Modal>')
return ctx
}
type ModalProps = {
open: boolean
onClose: () => void
children: ReactNode
}
function Modal({ open, onClose, children }: ModalProps) {
const value = useMemo(() => ({ open, onClose }), [open, onClose])
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<ModalContext.Provider value={value}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">{children}</div>
</div>
</ModalContext.Provider>
)
}
function ModalHeader({ children }: { children: ReactNode }) {
const { onClose } = useModal()
return (
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="text-lg font-bold">{children}</h2>
<button type="button" aria-label="Close" onClick={onClose}>
×
</button>
</header>
)
}
function ModalBody({ children }: { children: ReactNode }) {
return <div className="px-4 py-4">{children}</div>
}
function ModalFooter({ children }: { children: ReactNode }) {
return <footer className="flex justify-end gap-2 border-t px-4 py-3">{children}</footer>
}
Modal.Header = ModalHeader
Modal.Body = ModalBody
Modal.Footer = ModalFooter
export { Modal }Consumer — flexible layout
'use client'
import { useState } from 'react'
import { Modal } from './Modal'
export function DeleteAccountDialog() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Delete account
</button>
<Modal open={open} onClose={() => setOpen(false)}>
<Modal.Header>Delete account</Modal.Header>
<Modal.Body>
<p>This permanently removes your data.</p>
<label className="mt-4 flex items-center gap-2">
<input type="checkbox" />I understand this cannot be undone
</label>
</Modal.Body>
<Modal.Footer>
<button type="button" onClick={() => setOpen(false)}>
Cancel
</button>
<button type="button" className="bg-red-600 text-white">
Delete
</button>
</Modal.Footer>
</Modal>
</>
)
}5. Example: Accordion
Accordion manages expand/collapse — only one or many sections open at a time.
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type AccordionContextValue = {
openIds: Set<string>
toggle: (id: string) => void
allowMultiple: boolean
}
const AccordionContext = createContext<AccordionContextValue | null>(null)
function useAccordion() {
const ctx = useContext(AccordionContext)
if (!ctx) throw new Error('Accordion.Item must be used within <Accordion>')
return ctx
}
type AccordionProps = {
defaultOpen?: string[]
allowMultiple?: boolean
children: ReactNode
}
function Accordion({ defaultOpen = [], allowMultiple = false, children }: AccordionProps) {
const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpen))
const toggle = (id: string) => {
setOpenIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
if (!allowMultiple) next.clear()
next.add(id)
}
return next
})
}
const value = useMemo(() => ({ openIds, toggle, allowMultiple }), [openIds, allowMultiple])
return (
<AccordionContext.Provider value={value}>
<div className="divide-y rounded border">{children}</div>
</AccordionContext.Provider>
)
}
type ItemProps = {
id: string
title: ReactNode
children: ReactNode
}
function AccordionItem({ id, title, children }: ItemProps) {
const { openIds, toggle } = useAccordion()
const expanded = openIds.has(id)
return (
<div>
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
aria-expanded={expanded}
onClick={() => toggle(id)}>
{title}
<span aria-hidden>{expanded ? '−' : '+'}</span>
</button>
{expanded && <div className="px-4 pb-4 text-sm">{children}</div>}
</div>
)
}
Accordion.Item = AccordionItem
export { Accordion }// Usage
<Accordion defaultOpen={['shipping']} allowMultiple={false}>
<Accordion.Item id="shipping" title="Shipping policy">
Free shipping over $50…
</Accordion.Item>
<Accordion.Item id="returns" title="Returns">
30-day return window…
</Accordion.Item>
</Accordion>6. Common use cases
| Widget | Compound API shape | Shared state |
|---|---|---|
| Tabs | Tabs.List, Tabs.Tab, Tabs.Panel | Active tab id |
| Dropdown | Menu.Trigger, Menu.List, Menu.Item | Open, focus index |
| Table | Table.Root, Table.Header, Table.Row, Table.Cell | Sort column (optional) |
| Select | Select.Trigger, Select.Content, Select.Item | Open, selected value |
| Design systems | Dot-notation families across the library | Per-widget context |
Dropdown sketch
<Menu>
<Menu.Trigger>Options</Menu.Trigger>
<Menu.List>
<Menu.Item onSelect={edit}>Edit</Menu.Item>
<Menu.Item onSelect={archive}>Archive</Menu.Item>
</Menu.List>
</Menu>7. Practical task: Card
Build a Card compound family — header, optional image, content, footer — with no layout props on the root.
import type { ReactNode } from 'react'
function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
return <article className={`overflow-hidden rounded-lg border bg-white shadow-sm ${className}`}>{children}</article>
}
function CardImage({ src, alt }: { src: string; alt: string }) {
return <img src={src} alt={alt} className="h-48 w-full object-cover" />
}
function CardHeader({ children }: { children: ReactNode }) {
return <header className="border-b px-4 py-3 font-bold">{children}</header>
}
function CardContent({ children }: { children: ReactNode }) {
return <div className="px-4 py-4 text-sm leading-relaxed">{children}</div>
}
function CardFooter({ children }: { children: ReactNode }) {
return <footer className="flex gap-2 border-t bg-neutral-50 px-4 py-3">{children}</footer>
}
Card.Image = CardImage
Card.Header = CardHeader
Card.Content = CardContent
Card.Footer = CardFooter
export { Card }// Product card — image optional, footer optional
<Card>
<Card.Image src="/headphones.jpg" alt="Headphones" />
<Card.Header>Wireless Pro X</Card.Header>
<Card.Content>Noise cancelling, 40h battery, USB-C charging.</Card.Content>
<Card.Footer>
<button type="button">Add to cart</button>
<button type="button">Compare</button>
</Card.Footer>
</Card>
// Blog teaser — no image, no footer
<Card>
<Card.Header>React 19 Forms</Card.Header>
<Card.Content>How useActionState changes server forms…</Card.Content>
</Card>8. Practical task: Tabs
Full tabs implementation — the canonical compound exercise.
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type TabsContextValue = {
active: string
setActive: (id: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tabs.* must be used within <Tabs>')
return ctx
}
function Tabs({
defaultTab,
children,
onChange,
}: {
defaultTab: string
children: ReactNode
onChange?: (id: string) => void
}) {
const [active, setActiveState] = useState(defaultTab)
const setActive = (id: string) => {
setActiveState(id)
onChange?.(id)
}
const value = useMemo(() => ({ active, setActive }), [active])
return (
<TabsContext.Provider value={value}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }: { children: ReactNode }) {
return (
<div role="tablist" className="flex gap-1 border-b">
{children}
</div>
)
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { active, setActive } = useTabs()
const selected = active === id
return (
<button
type="button"
role="tab"
id={`tab-${id}`}
aria-selected={selected}
aria-controls={`panel-${id}`}
className={selected ? 'border-b-2 border-black font-bold' : ''}
onClick={() => setActive(id)}>
{children}
</button>
)
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { active } = useTabs()
if (active !== id) return null
return (
<div role="tabpanel" id={`panel-${id}`} aria-labelledby={`tab-${id}`} className="py-4">
{children}
</div>
)
}
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
export { Tabs }// Settings page — reorder List and Panels freely
<Tabs defaultTab="profile" onChange={(id) => analytics.track('tab', id)}>
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="security">Security</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">
<ProfileForm />
</Tabs.Panel>
<Tabs.Panel id="security">
<MfaSettings />
</Tabs.Panel>
<Tabs.Panel id="billing">
<BillingTable />
</Tabs.Panel>
</Tabs>9. Pitfalls & anti-patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
| Non-semantic subcomponents | Widget.Part7 with no meaning | Name by role: Header, Trigger, Panel |
| Separate re-exporting | import { ModalHeader } from './ModalHeader' | Keep dot notation on single export |
| Over-engineering simple layouts | Compound Card for a static <div> | Plain components until reuse appears |
| Missing context guard | Silent undefined when used outside parent | Throw in useModal() / useTabs() |
| Unstable context value | New object every render → mass re-renders | useMemo + stable callbacks |
| Exporting only subs | Consumers forget the provider | Document that root <Tabs> is required |
Summary
| Layer | Responsibility |
|---|---|
Parent (Modal, Tabs, Accordion) | State, context, keyboard/focus shell |
Sub-components (Header, Tab, Item) | Dumb UI, read context, render children |
| Consumer | Composes layout — no prop soup |
Compound components turn rigid widgets into composable Lego blocks. Fix prop soup with Context + dot notation, implement Modal and Accordion for shell behavior, and use the Card/Tabs tasks as templates for your design system.
Next reads: Slot Pattern for named insertion points vs compound composition, Container-Presenter Pattern for splitting smart logic from dumb UI, and React Design Patterns for render props and Provider alternatives.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime