50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 23, 2026·8 min read

Compound Components in React — The Pattern That Actually Scales

Compound components let you build flexible UI with shared state and zero prop drilling. Here's how the pattern works and when to use it.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Compound Components in React — The Pattern That Actually Scales

At some point you've written a component that accepts 14 props, half of them boolean flags, and the JSX inside looks like a nested if-statement sandwich. `showHeader`, `hideFooter`, `isCollapsible`, `renderCustomTitle`, `onToggle`, `defaultOpen`... and you haven't even added the edge cases yet. That component is trying to be everything to everyone and failing at all of it.

Compound components are the answer. Not the fashionable, write-it-in-a-tweet answer — the actually practical one you'll use in production and thank yourself for six months later. We've used this pattern in almost every template we've shipped, and it's the difference between a component library you enjoy using and one you dread touching.

What Are Compound Components, Actually

The idea is simple: instead of one giant component that controls everything through props, you build a set of smaller components that work together and share implicit state. Think of how HTML's `<select>` and `<option>` work. You don't pass all your options as a giant array prop to `<select>`. You nest `<option>` elements inside, and the browser handles the shared logic between them. Compound components bring that model to React.

The parent component holds state and exposes it via context. Each child component consumes that context and renders accordingly. From the outside, the API looks clean and composable. From the inside, the logic stays in one place. Everyone wins.

The Classic Example: A Disclosure / Accordion

Let's build a `Disclosure` component — the kind you'd use for an FAQ section or a collapsible settings panel. The naive approach gives you something like `<Disclosure title="What is this?" content="It's a thing." defaultOpen={false} />`. Works fine until someone needs a custom title with an icon, or wants to put a button inside the content, or needs the open state to sync with a URL param. Then you're adding props forever.

The compound component version looks like this at the call site:

<Disclosure defaultOpen={false}>
  <Disclosure.Trigger>
    <span>What is this?</span>
    <ChevronIcon />
  </Disclosure.Trigger>
  <Disclosure.Content>
    <p>It's a thing. A very flexible thing.</p>
    <Button>Learn more</Button>
  </Disclosure.Content>
</Disclosure>

Now the consumer controls what goes inside each slot. You're not fighting the component to customize it — you're just composing React normally.

Building It: Context + Static Properties

Here's a full implementation. Nothing clever, nothing magic:

import { createContext, useContext, useState, ReactNode } from 'react'

type DisclosureContextValue = {
  isOpen: boolean
  toggle: () => void
}

const DisclosureContext = createContext<DisclosureContextValue | null>(null)

function useDisclosure() {
  const ctx = useContext(DisclosureContext)
  if (!ctx) {
    throw new Error('useDisclosure must be used within a Disclosure component')
  }
  return ctx
}

// Root component — owns the state
function Disclosure({
  children,
  defaultOpen = false,
}: {
  children: ReactNode
  defaultOpen?: boolean
}) {
  const [isOpen, setIsOpen] = useState(defaultOpen)
  const toggle = () => setIsOpen((prev) => !prev)

  return (
    <DisclosureContext.Provider value={{ isOpen, toggle }}>
      <div>{children}</div>
    </DisclosureContext.Provider>
  )
}

// Trigger — knows about open state, calls toggle
function Trigger({ children }: { children: ReactNode }) {
  const { isOpen, toggle } = useDisclosure()

  return (
    <button
      onClick={toggle}
      aria-expanded={isOpen}
      className="flex w-full items-center justify-between"
    >
      {children}
    </button>
  )
}

// Content — renders when open
function Content({ children }: { children: ReactNode }) {
  const { isOpen } = useDisclosure()

  if (!isOpen) return null

  return <div role="region">{children}</div>
}

// Attach sub-components as static properties
Disclosure.Trigger = Trigger
Disclosure.Content = Content

export { Disclosure }

That's it. The `Disclosure` component owns `isOpen` and `toggle`. Any child component that needs those values calls `useDisclosure()` and gets them from context. The error in `useDisclosure` is important — it gives you a clear message instead of a mysterious `undefined is not a function` at 2am.

Always throw a useful error in your context hook when it's used outside the provider. Future you will not remember which component forgot to wrap things properly.

Going Further: Controlled vs Uncontrolled

The example above is uncontrolled — the component manages its own state. But sometimes you need the parent to drive the open state. Maybe it syncs with a URL param, or you have a "close all" button at the top of the page. This is where a lot of compound component implementations fall short.

You can support both patterns without much extra code:

function Disclosure({
  children,
  defaultOpen = false,
  open: controlledOpen,
  onOpenChange,
}: {
  children: ReactNode
  defaultOpen?: boolean
  open?: boolean
  onOpenChange?: (open: boolean) => void
}) {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)

  const isControlled = controlledOpen !== undefined
  const isOpen = isControlled ? controlledOpen : uncontrolledOpen

  const toggle = () => {
    const next = !isOpen
    if (!isControlled) {
      setUncontrolledOpen(next)
    }
    onOpenChange?.(next)
  }

  return (
    <DisclosureContext.Provider value={{ isOpen, toggle }}>
      <div>{children}</div>
    </DisclosureContext.Provider>
  )
}

If `open` is passed, the component is controlled — it never updates its own state, it just calls `onOpenChange` and lets the parent decide. If `open` is not passed, it manages itself. This is the same model React's own form inputs use, and it's the right one. We learned to build this in from the start after retrofitting it into a component that was already being used in 8 places and had to maintain backward compatibility. Not fun.

A More Complex Example: Tab Groups

Tabs are where this pattern really shines. The naive version has you passing arrays of objects as props, which means your JSX ends up looking like config files. The compound version is readable and flexible:

import { createContext, useContext, useState, ReactNode, useId } from 'react'

type TabsContextValue = {
  activeTab: string
  setActiveTab: (id: string) => void
}

const TabsContext = createContext<TabsContextValue | null>(null)

const useTabs = () => {
  const ctx = useContext(TabsContext)
  if (!ctx) throw new Error('useTabs must be used within Tabs')
  return ctx
}

function Tabs({
  children,
  defaultTab,
}: {
  children: ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div>{children}</div>
    </TabsContext.Provider>
  )
}

function TabList({ children }: { children: ReactNode }) {
  return (
    <div role="tablist" className="flex border-b">
      {children}
    </div>
  )
}

function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab, setActiveTab } = useTabs()
  const isActive = activeTab === id

  return (
    <button
      role="tab"
      aria-selected={isActive}
      onClick={() => setActiveTab(id)}
      className={isActive ? 'border-b-2 border-blue-500 font-medium' : 'text-gray-500'}
    >
      {children}
    </button>
  )
}

function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab } = useTabs()

  if (activeTab !== id) return null

  return <div role="tabpanel">{children}</div>
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

export { Tabs }

// Usage
// <Tabs defaultTab="account">
//   <Tabs.List>
//     <Tabs.Tab id="account">Account</Tabs.Tab>
//     <Tabs.Tab id="security">Security</Tabs.Tab>
//   </Tabs.List>
//   <Tabs.Panel id="account"><AccountForm /></Tabs.Panel>
//   <Tabs.Panel id="security"><SecurityForm /></Tabs.Panel>
// </Tabs>

Notice the accessibility attributes. `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"` — they fall out naturally when you structure your components this way, because each piece knows what it is. You'd have a harder time forgetting them with a prop-stuffed monolith.

When To Use This Pattern (And When Not To)

Compound components aren't always the move. Here's an honest breakdown:

  • Use it when consumers need layout control — they'll want to put things between your sub-components, reorder them, or skip one entirely.
  • Use it when you have shared state that multiple parts of a UI need to know about (selected tab, open/closed, active item).
  • Use it when you're building a component library or template that will be customized by others — it's API design, not just implementation.
  • Skip it for simple components. A `<Button>` doesn't need compound components. A `<Tooltip>` with two lines of logic probably doesn't either.
  • Skip it when all consumers will use the component the same way. If there's genuinely one right layout and zero variation needed, just hardcode it.

The mistake is reaching for it too early. We've done that. Ştefan once turned a `<StatusBadge>` into a compound component with three sub-components because he thought maybe someone would want to swap the icon. Nobody ever did. The simpler version would have been fine.

Start with props. When you hit your third boolean flag or second render prop, consider whether compound components would make this cleaner. Not before.

TypeScript Tips That Will Save You Pain

A few things that bite people when typing compound components:

  • Type your context as `Type | null` and throw in the hook — don't use non-null assertion. The runtime error message is worth more than saved keystrokes.
  • When attaching sub-components as static properties, TypeScript might complain about the type of the root function. Define the type explicitly and cast: `const Tabs = function(...) {} as TabsComponent & { List: ...; Tab: ...; Panel: ... }`
  • If you need sub-components to accept a `ref`, wrap them in `forwardRef` the same as you would any other component. The compound pattern doesn't change that.
  • Use `React.ComponentProps<'button'>` to spread native props through to the underlying element — lets consumers add `className`, `data-*` attributes, and event handlers without you explicitly supporting each one.

This pattern shows up throughout the component library in our peal.dev templates, particularly in things like modals, dropdowns, and settings panels where the layout flexibility actually matters. The types are worked out so you don't inherit that particular headache.

The Actual Benefit No One Talks About

Everyone talks about compound components as an API design pattern, and they're right. But the bigger payoff is what happens to your component internals. When state lives in context and each sub-component only cares about what it needs, the logic is easy to follow. `Trigger` knows about `toggle`. `Content` knows about `isOpen`. Neither knows about the other. When something breaks, you know exactly where to look.

Compare that to a 200-line component with 14 props where everything touches everything. You change one thing and have to trace through four conditional renders to understand the side effects. That's the component people are afraid to touch. Nobody wants to maintain it, nobody wants to extend it, and eventually someone rewrites it from scratch and the cycle continues.

Compound components are a forcing function for separation of concerns. Not because the pattern magically makes you write better code, but because the structure gives you no good place to dump the mess. You either keep things clean or the whole thing falls apart. Sometimes constraints are the best form of discipline.

The best component API is one where the user doesn't have to read your source code to figure out what's possible. Compound components, done right, are self-documenting by structure.

Start with your next dropdown or modal. Pull out the trigger and the content into separate sub-components, wire them together with context, and see if it doesn't feel better than the alternative. You won't go back.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders