| |

React 81 ⚛️ Compound Component Pattern

⏱️ Estimated read: 14 min | |

The compound component pattern is the React equivalent of HTML’s <select> and <option>. A native <select> manages its own state internally, and the <option> elements are configuration—they do not need to know how the dropdown works, only that they belong to it. The parent controls the behavior, the children declare the content, and the two communicate implicitly rather than through a long list of props. The pattern replicates this relationship in React: a parent component owns the shared state and exposes it through context, and the child components consume that context without the consumer having to wire anything up manually .

The alternative—a single component with a renderHeader, renderFooter, showAttachments, showFormatting, and showEmojis prop—starts simple and becomes a configuration monster. Every new variation adds another boolean or render function, and the consumer loses the ability to compose the pieces in a different order or wrap them in additional markup. The compound pattern replaces the prop explosion with composition: the consumer writes <Composer.Frame><Composer.Input /><Composer.Submit /></Composer.Frame>, and the components coordinate through context .

Key point: A compound component is a family of components that share implicit state through context. The parent owns the state, the children consume it, and the sub-components are attached as static properties on the parent (FlyOut.Toggle, FlyOut.List) so the consumer imports one thing and composes the pieces it needs.


Why the pattern exists

The prop explosion problem. A dropdown with a toggle, a list, and items can be built as a single component with isOpen, onToggle, items, renderItem, and onSelect props. It works for the first use case. Then the second use case needs the toggle to be a button with an icon, the third needs the list to be a <ul> instead of a <div>, and the fourth needs to wrap the items in a scroll container. Each variation adds a prop. The compound pattern removes the need for most of them because the consumer controls the markup .

The implicit state problem. The toggle button needs to know whether the list is open. The list needs to know the same thing. The items do not need to know anything—they are just content. Passing isOpen and onToggle through props to every child works, but it requires the consumer to wire the props correctly, and it couples the consumer to the internal state shape. Context makes the state implicit: the toggle and the list both read from the same provider, and the consumer does not see the wiring .

The composition problem. A monolithic component forces a specific structure. The compound pattern allows any structure that respects the parent-child relationship. The consumer can put the toggle before the list, after the list, inside a wrapper, or conditionally render the list. The only requirement is that the pieces are descendants of the provider .

The React.Children.map limitation. The older implementation cloned the parent’s direct children and injected the state as props. It worked for simple cases, but it failed when a child was wrapped in another component, because the wrapper did not receive the injected props and could not pass them down. Context solves this: any descendant can read the context regardless of how deeply it is nested .


a. The context-based implementation

The pattern has four parts: the context, the provider (the parent component), the custom hook for safe consumption, and the sub-components attached to the parent.

// 1. Create the context with a null default
//    The null forces a runtime error if a child is used outside the provider
import { createContext, useContext, useState, useMemo } from 'react'

const FlyOutContext = createContext(null)

// 2. The custom hook validates that the context exists
//    This is the safety net that catches misuse early
function useFlyOut() {
  const context = useContext(FlyOutContext)
  if (context === null) {
    throw new Error('FlyOut components must be used within <FlyOut>')
  }
  return context
}

// 3. The parent component owns the state
//    It wraps its children in the provider
function FlyOut({ children }) {
  const [open, setOpen] = useState(false)

  // Memoize the context value to avoid re-rendering all consumers
  // when the parent re-renders for unrelated reasons
  const value = useMemo(() => ({ open, setOpen }), [open])

  return (
    <FlyOutContext.Provider value={value}>
      <div className="flyout">{children}</div>
    </FlyOutContext.Provider>
  )
}

The useMemo is not optional for a component that re-renders frequently. The context value is an object literal, and a new object on every render causes every consumer to re-render, even when open has not changed. Memoizing the value with [open] as the dependency ensures that the consumers only re-render when the state they actually depend on changes .

The sub-components read from the hook and render their own markup:

// 4. The Toggle button reads the state and calls setOpen
function Toggle({ children }) {
  const { open, setOpen } = useFlyOut()
  return (
    <button onClick={() => setOpen(!open)} aria-expanded={open}>
      {children}
    </button>
  )
}

// The List renders only when open is true
function List({ children }) {
  const { open } = useFlyOut()
  if (!open) return null
  return <ul className="flyout-list">{children}</ul>
}

// The Item is plain content; it does not need the context
function Item({ children }) {
  return <li className="flyout-item">{children}</li>
}

The Item does not call useFlyOut because it does not depend on the open state. Making every child consume the context would cause every item to re-render when the state changes, which is wasteful for a list that might contain dozens of items. The pattern is to consume the context only where the state is needed.

The attachment is the final step:

// Attach the sub-components as static properties
FlyOut.Toggle = Toggle
FlyOut.List = List
FlyOut.Item = Item

// Usage
function Menu() {
  return (
    <FlyOut>
      <FlyOut.Toggle>Actions</FlyOut.Toggle>
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  )
}

The consumer imports FlyOut and nothing else. The sub-components are properties on the parent, which means the import surface is one symbol and the relationship between the pieces is visible in the JSX .


b. The React.Children.map alternative and its limits

Before context became the standard, the pattern was implemented by mapping over the children and cloning them with the injected props. The parent component iterated props.children and used React.cloneElement to add open and toggle to each direct child .

// The older implementation
function FlyOut({ children }) {
  const [open, setOpen] = useState(false)

  // Map over the direct children and clone each one
  // with the state injected as props
  return (
    <div>
      {React.Children.map(children, (child) =>
        React.cloneElement(child, { open, setOpen })
      )}
    </div>
  )
}

The limitation is the direct-child requirement. If the consumer wraps the toggle in a <div>, the <div> receives the injected props, but it does not pass them to the toggle, and the toggle breaks. The context approach does not have this problem because the toggle reads from the context directly, regardless of how deeply it is nested . The cloneElement approach also performs a shallow merge of props, which means an existing prop with the same name is overwritten, and a naming collision is possible .

The context approach is the recommended one for any component that needs to be composable beyond the simplest case. The Children.map approach survives in legacy code and in very simple components, but it is not the pattern to reach for when building a library.


c. When to use it and when not to

The pattern is the right choice for a family of components that share meaningful state: tabs, accordions, dropdown menus, dialogs, and multi-step forms. These are the cases where the consumer needs to arrange the pieces flexibly and where the internal state management should be hidden from the consumer .

It is the wrong choice for a simple one-off UI where a single component with props is clearer. A button with a label and an onClick handler does not need a context, a provider, and three sub-components. The pattern adds indirection, and the indirection is only worth it when the flexibility is actually used .

It is also the wrong choice when the implicit state makes the component hard to predict. The consumer of a compound component does not see the state, which is the point, but it also means the consumer cannot easily override the behavior. If the consumer needs to control the state—for example, to synchronize two accordions—the pattern should be adapted to accept a controlled open prop and an onOpenChange callback, rather than owning the state internally .

The TypeScript surface is the other consideration. Attaching sub-components to the parent requires a type that expresses both the parent’s props and the static properties:

// The type for a compound component
type FlyOutComponent = React.FC<{ children: React.ReactNode }> & {
  Toggle: typeof Toggle
  List: typeof List
  Item: typeof Item
}

The intersection type is what allows <FlyOut.Toggle /> to be type-checked. Without it, the compiler sees FlyOut as a function and complains that Toggle does not exist on it .


Complete Example Session

The following session builds an accordion with the compound pattern, using context, a custom hook, and memoized state.

// ============================================
// PART 1: THE CONTEXT AND HOOK
// ============================================
import { createContext, useContext, useState, useCallback, useMemo } from 'react'

// The context holds the open items and the toggle function
const AccordionContext = createContext(null)

// The hook throws if a child is used outside the provider
function useAccordion() {
  const context = useContext(AccordionContext)
  if (context === null) {
    throw new Error('Accordion components must be used within <Accordion>')
  }
  return context
}

// ============================================
// PART 2: THE PARENT COMPONENT
// ============================================
function Accordion({ allowMultiple = false, children }) {
  // The state is a Set of open item IDs
  const [openItems, setOpenItems] = useState(new Set())

  // The toggle function adds or removes an ID
  const toggle = useCallback((id) => {
    setOpenItems((prev) => {
      const next = new Set(prev)
      if (next.has(id)) {
        next.delete(id)
      } else {
        if (!allowMultiple) next.clear()
        next.add(id)
      }
      return next
    })
  }, [allowMultiple])

  // Memoize the value so consumers only re-render when openItems changes
  const value = useMemo(() => ({ openItems, toggle }), [openItems, toggle])

  return (
    <AccordionContext.Provider value={value}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  )
}

// ============================================
// PART 3: THE SUB-COMPONENTS
// ============================================
function AccordionItem({ id, children }) {
  const { openItems } = useAccordion()
  const isOpen = openItems.has(id)
  return <div className={`accordion-item ${isOpen ? 'open' : ''}`}>{children}</div>
}

function AccordionTrigger({ itemId, children }) {
  const { toggle } = useAccordion()
  return (
    <button className="accordion-trigger" onClick={() => toggle(itemId)}>
      {children}
    </button>
  )
}

function AccordionContent({ itemId, children }) {
  const { openItems } = useAccordion()
  if (!openItems.has(itemId)) return null
  return <div className="accordion-content">{children}</div>
}

// ============================================
// PART 4: ATTACH THE SUB-COMPONENTS
// ============================================
Accordion.Item = AccordionItem
Accordion.Trigger = AccordionTrigger
Accordion.Content = AccordionContent

// ============================================
// PART 5: USAGE
// ============================================
function FAQ() {
  return (
    <Accordion allowMultiple>
      <Accordion.Item id="q1">
        <Accordion.Trigger itemId="q1">What is React?</Accordion.Trigger>
        <Accordion.Content itemId="q1">
          <p>React is a JavaScript library for building user interfaces.</p>
        </Accordion.Content>
      </Accordion.Item>
      <Accordion.Item id="q2">
        <Accordion.Trigger itemId="q2">What are hooks?</Accordion.Trigger>
        <Accordion.Content itemId="q2">
          <p>Hooks let you use state and other React features in function components.</p>
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  )
}

The five parts cover the context and hook, the parent component with memoized state, the sub-components that consume the context, the attachment, and the usage. What the session demonstrates is that the consumer never passes isOpen or onToggle—the state is implicit, and the composition is explicit.


Quick Reference

Pattern Structure

ComponentRole
ParentOwns state, provides context
Custom hookValidates context, returns state
Sub-componentsConsume context, render markup
AttachmentParent.Child = Child

When to Use

SituationUse Compound?
Tabs, accordion, dropdownYes
Dialog with header/body/footerYes
Single button with propsNo
Two components with no shared stateNo
Consumer needs full state controlAdapt to controlled

Implementation Choices

ApproachNestingRecommended
ContextAny depthYes
React.Children.mapDirect children onlyLegacy
cloneElementDirect children, prop collisionsLegacy

TypeScript Surface

PieceType
ParentReact.FC<Props> & { Child: typeof Child }
ContextcreateContext<Value | null>(null)
HookuseContext + null check

Best Practices

✅ Do This:

// Use a null default and a custom hook
const Context = createContext(null)
function useThing() {
  const ctx = useContext(Context)
  if (!ctx) throw new Error('Used outside provider')
  return ctx
}

// Memoize the context value
const value = useMemo(() => ({ open, toggle }), [open, toggle])

// Attach sub-components
Parent.Child = Child

❌ Don’t Do This:

// Use a default object that hides misuse
const Context = createContext({ open: false, toggle: () => {} }) // silent failure

// Create a new object on every render
<Context.Provider value={{ open, toggle }}> // re-renders consumers

// Make every child consume the context
function Item() { const { open } = useThing() } // unnecessary re-renders

Common Pitfalls

PitfallWhy It HappensFix
Child throws “must be used within”Rendered outside providerWrap in parent
Consumers re-render on every parent renderNew context objectuseMemo
Every item re-rendersItem consumes context unnecessarilyOnly consume where needed
TypeScript error on Parent.ChildType does not include staticIntersection type
React.Children.map breaks with wrappersOnly direct children clonedUse context

Real-World Examples

1. Context and hook

const Ctx = createContext(null)
function useCtx() { const c = useContext(Ctx); if (!c) throw new Error(); return c }

2. Parent provider

function Parent({ children }) {
  const [open, setOpen] = useState(false)
  return <Ctx.Provider value={{ open, setOpen }}>{children}</Ctx.Provider>
}

3. Memoized value

const value = useMemo(() => ({ open, setOpen }), [open])

4. Sub-component consumption

function Toggle() { const { open, setOpen } = useCtx(); return <button onClick={() => setOpen(!open)} /> }

5. Attachment

Parent.Toggle = Toggle
Parent.List = List

6. Tabs pattern

<Tabs><Tabs.List><Tabs.Tab id="a">A</Tabs.Tab></Tabs.List><Tabs.Panels>...</Tabs.Panels></Tabs>

7. Accordion pattern

<Accordion><Accordion.Item id="1"><Accordion.Trigger itemId="1" /><Accordion.Content itemId="1" /></Accordion.Item></Accordion>

8. Controlled adaptation

function Parent({ open: controlledOpen, onOpenChange, children }) { /* ... */ }

9. TypeScript intersection

type ParentComponent = React.FC<Props> & { Child: typeof Child }

10. Radix-style API

<Dialog.Root><Dialog.Trigger /><Dialog.Content /></Dialog.Root>

Visual

Compound Component Structure

┌──────────────────────────────────────────────┐
│  <FlyOut>                                    │
│  ┌────────────────────────────────────────┐  │
│  │  FlyOutContext.Provider                │  │
│  │  value = { open, setOpen }             │  │
│  │         │                              │  │
│  │         ▼                              │  │
│  │  <FlyOut.Toggle>  ← reads context      │  │
│  │  <FlyOut.List>    ← reads context      │  │
│  │    <FlyOut.Item>  ← plain content      │  │
│  │  </FlyOut.List>                        │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

Context vs Children.map

┌──────────────────────────────────────────────┐
│  CONTEXT                                     │
│  Any descendant can read                     │
│  Works with wrappers                         │
│  Recommended                                 │
│                                              │
│  CHILDREN.MAP                                │
│  Only direct children cloned                 │
│  Breaks when wrapped                         │
│  Legacy                                      │
└──────────────────────────────────────────────┘

State Flow

┌──────────────────────────────────────────────┐
│  Parent owns state                           │
│         │                                    │
│         ▼                                    │
│  Context provides { open, toggle }           │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│    ▼         ▼                               │
│  Toggle    List                              │
│  reads     reads                             │
│  open      open                              │
│  calls     renders                           │
│  toggle    conditionally                     │
└──────────────────────────────────────────────┘

Summary

ItemValue
PatternFamily of components sharing implicit state
State ownerParent component
State sharingReact Context
Safe consumptionCustom hook with null check
API surfaceSub-components as static properties
MemoizationuseMemo on context value
Legacy alternativeReact.Children.map + cloneElement
Best forTabs, accordions, dropdowns, dialogs

Key takeaways:

  • The parent owns the state and provides it through context. The children consume the context without the consumer wiring props. The relationship is implicit, and the composition is explicit .
  • The custom hook is the safety net. A null default and a hook that throws when the context is missing catches misuse at the point where the component is rendered, not at the point where the state is accessed .
  • Memoize the context value. A new object on every render causes every consumer to re-render, even when the state has not changed. useMemo with the state as the dependency is the fix .
  • Attach sub-components as static properties. FlyOut.Toggle = Toggle makes the API surface a single import and makes the relationship visible in the JSX .
  • Context is better than Children.map. The Children.map approach only works for direct children and breaks when the consumer wraps a child in another element. Context works at any nesting depth .
  • Consume the context only where needed. The Item component does not need the open state, so it should not call the hook. Making every child a consumer causes unnecessary re-renders .
  • The pattern is for families, not for single components. A button with a label and a click handler does not need a context, a provider, and three sub-components. The indirection is only worth it when the flexibility is actually used .

Remember: The compound component pattern is the React expression of a native HTML relationship. <select> and <option> work together without the consumer wiring anything, and the compound pattern replicates that. The parent owns the state, the context shares it, the custom hook validates it, and the sub-components consume it. The result is an API that is declarative, flexible, and free of prop explosion—the consumer writes the structure it needs, and the components coordinate behind the scenes. The pattern is not for every component. It is for the families that genuinely share state and genuinely benefit from flexible composition.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!