Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsCompound ComponentsContext APIComponent Libraries

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.

Jul 3, 202614 min read

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:

  1. The problem — prop soup and rigid monoliths
  2. The solution — compositional, dumb sub-components
  3. Core implementation — Context, dot notation, children
  4. Examples — Modal shell and Accordion expand/collapse
  5. Common use cases — tabs, dropdowns, tables, libraries
  6. Pitfalls — over-engineering and broken composition
  7. Practical tasks — full Card and Tabs implementations

Note

Compound components share implicit state through Context (or historically cloneElement). Consumers arrange markup freely; the parent owns open/closed, active tab, or expanded index.

Quick index

#Section
1The problem: prop soup
2The solution: Lego blocks
3Core implementation
4Example: Modal
5Example: Accordion
6Common use cases
7Practical task: Card
8Practical task: Tabs
9Pitfalls & 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

tsx
// ❌ 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 symptomWhat breaks
Bloated componentsOne file owns shell, header, body, footer, and actions
Rigid structurefooterAlign, showFooter — layout locked by author
Lack of flexibilityCannot insert a checkbox between body and footer
Mixed responsibilitiesFetch logic, ARIA, and styling intertwined
Hard to test and scaleEvery new layout needs a new prop

Warning

If your component accepts more than ~8 layout or visibility props (showX, renderX, xPosition), you are fighting prop soup — compound composition is the escape hatch.

Back to index


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.

plaintext
<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>
PrincipleMeaning
Flexible building blocksConsumers pick which sub-parts to render
Compositional designComplex UI = tree of small components
Separation of concernsParent = state + context; children = markup
Dumb sub-componentsNo fetch, no business rules — context + props only

Tip

Think HTML <select> + <option> — the platform compound API. React compound components bring the same ergonomics to design-system widgets.

Back to index


3. Core implementation

Every compound family follows the same recipe.

StepImplementation
1. Parent containerHolds state (open, activeId, expandedIndex)
2. ContextShares state + setters to descendants
3. Sub-componentsRead context via custom hook (useModal, useTabs)
4. Dot notationAttach subs to parent: Modal.Header = Header
5. Single default exportExport Modal with all subs attached

Minimal skeleton

tsx
'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)

ApproachProsCons
Context (modern)Any depth, TypeScript-friendly, explicit hookSlightly more boilerplate
cloneElement (legacy)No Context providerFragile, breaks with wrappers, hard to type

Note

Libraries like Reach UI and Radix use Context under the hood. Prefer Context for new compound APIs — cloneElement is legacy unless you maintain older code.

Performance

Memoize context value with useMemo when the provider wraps large subtrees. A new object every render re-renders all context consumers.

Back to index


4. Example: Modal

Modal demonstrates shell behavior — open/close state shared across header, body, and footer regions.

tsx
'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

tsx
'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>
    </>
  )
}

Tip

The checkbox between body and footer is impossible with the monolithic body={...} prop API — compound layout wins when consumers need insertion points.

Back to index


5. Example: Accordion

Accordion manages expand/collapse — only one or many sections open at a time.

tsx
'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 }
tsx
// 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>

Note

Accordion.Item combines trigger + panel in one sub-component for simplicity. Larger APIs split into Accordion.Trigger and Accordion.Content (Radix-style) for maximum layout freedom.

Back to index


6. Common use cases

WidgetCompound API shapeShared state
TabsTabs.List, Tabs.Tab, Tabs.PanelActive tab id
DropdownMenu.Trigger, Menu.List, Menu.ItemOpen, focus index
TableTable.Root, Table.Header, Table.Row, Table.CellSort column (optional)
SelectSelect.Trigger, Select.Content, Select.ItemOpen, selected value
Design systemsDot-notation families across the libraryPer-widget context

Dropdown sketch

tsx
<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>

Tip

In component libraries (shadcn/ui, Radix, Headless UI), compound APIs are the default export shape. Study their source when designing your own primitives.

Back to index


7. Practical task: Card

Build a Card compound family — header, optional image, content, footer — with no layout props on the root.

tsx
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 }
tsx
// 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>

Note

Static Card sub-components do not need Context — no shared open/active state. Compound here means consistent dot-notation API, not always implicit state. Add Context only when subs must coordinate.

Back to index


8. Practical task: Tabs

Full tabs implementation — the canonical compound exercise.

tsx
'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 }
tsx
// 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>

Interview Answer

"Why compound components instead of render props for Tabs?"

Compound components give a declarative, readable tree that matches the DOM structure — designers and reviewers see <Tabs.List> and <Tabs.Panel> at a glance. Render props excel when consumers need full control over rendered output from shared logic; compound + Context is the standard for design-system widgets where structure is predictable but layout varies.

Back to index


9. Pitfalls & anti-patterns

Anti-patternProblemFix
Non-semantic subcomponentsWidget.Part7 with no meaningName by role: Header, Trigger, Panel
Separate re-exportingimport { ModalHeader } from './ModalHeader'Keep dot notation on single export
Over-engineering simple layoutsCompound Card for a static <div>Plain components until reuse appears
Missing context guardSilent undefined when used outside parentThrow in useModal() / useTabs()
Unstable context valueNew object every render → mass re-rendersuseMemo + stable callbacks
Exporting only subsConsumers forget the providerDocument that root <Tabs> is required

Warning

Do not use compound components for a one-off page section that never reuses structure. A plain <section> with three divs is fine — patterns pay off in libraries and repeated widgets.

Warning

Breaking dot notation (export { ModalHeader } separately) fragments the API and invites inconsistent imports across the codebase. Attach subs to the parent: Modal.Header = ModalHeader.

Tip

Production checklist

  • Parent owns shared state; subs read via useX() hook with guard
  • Dot notation on single default export
  • Memoize context value; stable useCallback for toggles
  • ARIA roles on tabs, accordion, modal, menu
  • Throw when subs render outside provider
  • Context for coordination; skip Context when subs are purely presentational (Card)
  • Do not compound a static layout that appears once

Back to index


Summary

LayerResponsibility
Parent (Modal, Tabs, Accordion)State, context, keyboard/focus shell
Sub-components (Header, Tab, Item)Dumb UI, read context, render children
ConsumerComposes 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime