Skip to content

Compound components

A menu component can be written as one black box: pass it buttonText and an items array, and it renders everything itself. That version works until it doesn't. The caller can't change how an item renders, can't reorder the pieces, can't reuse the button elsewhere, and every prop has to travel through Menu even when Menu itself never uses it. Compound components take the opposite approach: several small components designed to work together, with the caller composing them like markup.

HTML has worked this way from the start. A <select> is nothing without its <option>s, a <ul> styles the <li>s inside it, a <form> tracks the inputs it wraps, and the table family is a whole ecosystem of elements that only make sense together. Compound components bring that parent-and-children contract to your own React components.

The prop drilling problem

The monolithic menu drills in miniature: Menu receives buttonText and items purely to hand them to MenuButton and MenuDropdown. It cares about neither. That relay is prop drilling, and lifting state up covers when a layer or two of it is normal and when it stretches far enough to need a different tool. Across many layers it turns components into couriers, and refactoring any middle layer means rethreading the chain.

Sometimes the right fix is nothing. Drilling one or two levels is cheaper than a premature abstraction, and hasty abstractions cost more than the repetition they remove. The patterns below earn their keep when the drilling is real.

Flattening the structure

The compound version turns Menu into a wrapper that renders its children, and promotes the inner pieces to the caller's level:

jsx
const sports = ['Tennis', 'Pickleball', 'Racquetball', 'Squash']

<Menu>
  <MenuButton>Sports</MenuButton>
  <MenuDropdown>
    {sports.map(sport => (
      <MenuItem key={sport}>{sport}</MenuItem>
    ))}
  </MenuDropdown>
</Menu>

Each piece stays small and does one job, and the bodies behind that markup are close to one-liners:

jsx
function Menu({ children }) {
  return <div className="menu">{children}</div>
}

function MenuButton({ children }) {
  return <Button>{children}</Button>
}

function MenuDropdown({ children }) {
  return <div className="menu-dropdown">{children}</div>
}

function MenuItem({ children }) {
  return <div className="menu-item">{children}</div>
}

MenuButton reuses the Button from the composition chapter. The structure that used to be hidden inside Menu is now visible at the call site, and the data slides directly to the component that uses it: the array is mapped right where it's needed, with no relay through Menu. Want each item to be a link instead of text? Put an <a> inside MenuItem. The component's author never has to anticipate that request.

The trade is transparency for verbosity. The caller writes more JSX and gets fine-grained control over every layer; the black box is gone in both senses.

The pieces are separate components, so a caller needs an import for each one. Libraries usually smooth that over by attaching the subcomponents to the parent before exporting it, which is plain JavaScript: functions are objects, so they can carry properties.

jsx
Menu.Button = MenuButton
Menu.Dropdown = MenuDropdown
Menu.Item = MenuItem
export default Menu

One import brings the whole family, and the call site, <Menu.Button>, advertises that the pieces belong together. The dot syntax is naming and nothing more. Whatever makes the pieces work together has to come from somewhere else, which is the gap the next section opens.

The missing piece: implicit state

The flattened menu has a hole in it: the button doesn't open the dropdown anymore. The open/closed boolean and its toggle function live in Menu, and Menu renders only its wrapping div and {children}. There's no prop being passed to MenuButton, so there's nowhere to hang the toggle. The pieces need to share state without the caller wiring it through every tag, which the community calls implicit state: the compound parts coordinate behind the scenes while the caller composes them.

React ships an API that can fake this. Children.map loops over a component's direct children (props.children has no guaranteed shape: several children give you an array, a single child gives you the element itself, and none gives you undefined, so calling .map() on it works right up until it doesn't), and cloneElement copies an element while injecting extra props:

jsx
import { Children, cloneElement, useState } from 'react'

function Menu({ children }) {
  const [open, setOpen] = useState(false)
  const toggle = () => setOpen(prevOpen => !prevOpen)

  return (
    <div className="menu">
      {Children.map(children, child =>
        cloneElement(child, { open, toggle })
      )}
    </div>
  )
}

Now every direct child of Menu silently receives open and toggle, and the button works again. It also breaks the moment anyone breathes on it, which is the real lesson.

Where Children.map falls short

There are two shortcomings, and both are structural. First, it's fragile: the mapping only touches direct children, so if a caller wraps MenuDropdown in a styling <div>, the div receives toggle and open instead, React complains in the console that a function is not a valid value for a DOM attribute, and the menu stops working. A compound component that forbids wrapping its pieces has given up the flexibility that justified it.

Second, it's depth-limited: MenuItem is a grandchild, so it receives nothing. Getting state to it means repeating the Children.map-plus-cloneElement dance inside MenuDropdown too, one relay per layer, which is prop drilling wearing a costume.

What the pattern actually needs is a way to teleport state from Menu to any descendant, however deep and however wrapped. That is exactly what context does, and the context chapter picks up this same menu and finishes it: Menu provides { open, toggle } to everything it wraps, and MenuButton reads the toggle with useContext no matter what sits between them.

Version note

The React docs now file Children and cloneElement under legacy APIs and recommend alternatives, context among them, for new code. They still work and still appear in plenty of existing codebases, which is why they're worth recognizing on sight. The course lessons and most older code write them with the default import as React.Children and React.cloneElement.

Deciding whether a component should be compound at all comes down to who needs control of the middle. A component whose internal layout is fixed and unlikely to change is better off monolithic with a couple of props; the compound pattern pays for itself when callers keep asking for one more rendering option and the prop list is turning into a configuration language. Disclosure widgets, menus, tabs, and accordions live in that zone, which is why every mainstream headless UI library ships them in compound form.

Shipping a family has two costs worth pricing in. Attaching the pieces to the parent ties them together for the bundler, so importing Menu pulls in every subcomponent whether or not the page renders them, and a rarely used piece rides along in the chunk regardless.

The second cost is that a compound API can describe its expected structure without ever enforcing it: nothing stops a caller from rendering MenuDropdown with no MenuButton above it, and policing that by comparing a child's type against the component identity breaks as soon as a wrapper sits in between or two copies of the module land in one build. The version that holds up documents the intended shape and makes each piece behave sensibly on its own when the shape is broken.

JunoSmall pieces that work together Instead of one big Menu component that takes a pile of props and renders everything, compound components give you several small tags, a Menu, a MenuButton, a MenuDropdown, a MenuItem, that you arrange yourself the way you arrange a select and its options in HTML. You see the whole structure right where you write it, and each piece gets its content directly.

The catch is the pieces still need to share things like "is the menu open," and the context chapter shows the clean way they do it.

JunoSmall pieces that work together Reach for compound components when a single component's props are turning into a configuration language: expose the pieces, let the caller compose them, and map data at the call site instead of relaying it through a wrapper.

Children.map with cloneElement can inject shared state into direct children, but it breaks under a wrapping div and never reaches grandchildren, so treat it as a stepping stone and use context for the real coordination.

JunoSmall pieces that work together Compound components trade an opaque single-component API for a transparent composable one; make the trade when callers need control of the middle layers, and skip it when the layout is fixed.

Coordinate the pieces through a scoped context provider rather than Children.map and cloneElement, which are legacy APIs, fragile under wrapping, and depth-limited by design.

Shipping the pieces as a family has a price: attaching them to the parent keeps the bundler from dropping the ones a page never renders, and the API can document its expected structure without ever enforcing it.

Next up: Context, the tool that lets those pieces share state however deep they sit.