EmpireUI
Get Pro
← Blog8 min read#compound components#react#patterns

React Compound Components Pattern: Flexible APIs Without Prop Hell

Stop drowning in prop hell. Learn the compound components pattern in React — build flexible, composable APIs that your teammates will actually want to use.

Developer writing React component code on a dark theme editor

What Even Is Prop Hell?

If you've shipped a component library — or inherited one — you know the feeling. You start with a simple <Modal> that takes a title prop. Fine. Then someone needs a subtitle. Add it. Then a close button with a custom icon. Add that too. Then footer actions, a loading state, a scrollable body, a sticky header, different size variants... and suddenly your component signature looks like this:

<Modal
  title="Delete Account"
  subtitle="This action cannot be undone"
  closeIcon={<XCircleIcon />}
  footerLeftContent={<Checkbox label="I understand" />}
  footerRightContent={<Button>Confirm</Button>}
  isLoading={deleting}
  size="lg"
  scrollableBody
  stickyHeader
  onClose={handleClose}
  onConfirm={handleDelete}
/>

That's 12 props, and you're still missing half the requirements someone will file next week. The component is impossible to extend without touching its internals. And the internal if (footerLeftContent) branching makes the code a nightmare to read. There has to be a better way — and there is.

Compound components let you split that monolithic API into small, focused sub-components that compose naturally in JSX. The pattern's been around since at least React 16, but it's still underused, probably because the first explanation most devs encounter is way too abstract.

The Core Idea: Share State via Context

Here's the thing — compound components work by having a parent component own the shared state and pass it down through React Context, not through props. The child sub-components read from that context and render accordingly. No prop drilling. No render props gymnastics. Just clean JSX composition.

Let's rebuild that Modal from scratch. First, the context:

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

type ModalContextValue = {
  isOpen: boolean;
  close: () => void;
};

const ModalContext = createContext<ModalContextValue | null>(null);

export function useModal() {
  const ctx = useContext(ModalContext);
  if (!ctx) throw new Error('useModal must be used inside <Modal>');
  return ctx;
}

Then the root Modal component that owns the state:

type ModalProps = {
  children: ReactNode;
  defaultOpen?: boolean;
};

export function Modal({ children, defaultOpen = false }: ModalProps) {
  const [isOpen, setIsOpen] = useState(defaultOpen);

  return (
    <ModalContext.Provider value={{ isOpen, close: () => setIsOpen(false) }}>
      {children}
    </ModalContext.Provider>
  );
}

That's it for the foundation. Now you add sub-components as named exports — Modal.Trigger, Modal.Content, Modal.Header, Modal.Body, Modal.Footer. Each one reads context as needed. The parent doesn't care what you put inside it.

Building the Sub-Components

Let's wire up the pieces. Each sub-component is just a regular React component that optionally reads from context. Nothing magic:

Modal.Trigger = function ModalTrigger({ children }: { children: ReactNode }) {
  // In a real impl you'd toggle open state here
  // Keeping it minimal for clarity
  return <div className="modal-trigger">{children}</div>;
};

Modal.Content = function ModalContent({ children }: { children: ReactNode }) {
  const { isOpen } = useModal();
  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-panel" role="dialog" aria-modal>
        {children}
      </div>
    </div>
  );
};

Modal.Header = function ModalHeader({ children }: { children: ReactNode }) {
  const { close } = useModal();
  return (
    <div className="modal-header">
      <div>{children}</div>
      <button onClick={close} aria-label="Close">&times;</button>
    </div>
  );
};

Modal.Body = function ModalBody({ children }: { children: ReactNode }) {
  return <div className="modal-body">{children}</div>;
};

Modal.Footer = function ModalFooter({ children }: { children: ReactNode }) {
  return <div className="modal-footer">{children}</div>;
};

Now the call site reads like you're writing normal HTML — and you can put whatever you want inside each slot:

<Modal>
  <Modal.Trigger>
    <button>Delete Account</button>
  </Modal.Trigger>

  <Modal.Content>
    <Modal.Header>Are you sure?</Modal.Header>
    <Modal.Body>
      <p>This cannot be undone. Seriously.</p>
      <Checkbox label="I understand the consequences" />
    </Modal.Body>
    <Modal.Footer>
      <Button variant="ghost" onClick={close}>Cancel</Button>
      <Button variant="destructive" onClick={handleDelete}>Delete</Button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

Honestly, that's so much cleaner. You've gone from 12 props with no escape hatch to a fully flexible composition API where callers own the layout. If a designer wants to swap the footer for something completely custom, they just... put different children in <Modal.Footer>. No PR needed on the library itself.

Worth noting: attaching sub-components as static properties (Modal.Header = ...) is a stylistic choice. Some teams prefer named exports (ModalHeader, etc.) and a barrel file. Both work. The static property approach bundles them conceptually and makes autocomplete really nice in VSCode.

Controlled vs Uncontrolled — You Need Both

The example above is uncontrolled — the modal manages its own isOpen state internally. That's great for simple cases. But sometimes you need to control the open state externally, like when a server action finishes and you want to close the modal programmatically. Compound components handle this just fine with a controlled pattern:

type ModalProps = {
  children: ReactNode;
} & (
  | { open?: never; onOpenChange?: never }        // uncontrolled
  | { open: boolean; onOpenChange: (v: boolean) => void }  // controlled
);

export function Modal({ children, open, onOpenChange }: ModalProps) {
  const [internalOpen, setInternalOpen] = useState(false);

  const isOpen = open ?? internalOpen;
  const setOpen = onOpenChange ?? setInternalOpen;

  return (
    <ModalContext.Provider value={{ isOpen, close: () => setOpen(false) }}>
      {children}
    </ModalContext.Provider>
  );
}

Now your component works both ways without duplicating logic. The same pattern Radix UI uses for every one of their primitives since v1.0 in 2022. In practice, this dual-mode approach saves you from having to maintain two separate components, and callers don't have to think about it until they actually need control.

Quick aside: if you're building on top of Radix or similar headless libs, compound components fit like a glove. Radix exposes exactly this API — Dialog.Root, Dialog.Trigger, Dialog.Content, etc. You're probably already using them without realizing you're using this pattern.

One more thing — TypeScript makes this pattern safer. Defining the context value type upfront (ModalContextValue) means the useModal() hook gives you full autocomplete and catches you if you forget to wrap a sub-component inside the root. The throw new Error(...) in the hook isn't just defensive coding — it's actually the fastest way to diagnose a mis-used component at dev time. Way better than a silent undefined crash 6px deep in a render tree.

Real-World Patterns: Tabs, Accordions, and More

Modals are just the warm-up. The compound components pattern shines brightest for interactive navigation — tabs, accordions, dropdowns, steppers. Let's look at a minimal Tabs implementation:

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('Must be inside <Tabs>');
  return ctx;
}

export function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
  const [active, setActive] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

Tabs.List = function TabsList({ children }: { children: ReactNode }) {
  return <div className="tabs-list" role="tablist">{children}</div>;
};

Tabs.Tab = function TabsTab({ id, children }: { id: string; children: ReactNode }) {
  const { active, setActive } = useTabs();
  return (
    <button
      role="tab"
      aria-selected={active === id}
      className={active === id ? 'tab-active' : 'tab'}
      onClick={() => setActive(id)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function TabsPanel({ id, children }: { id: string; children: ReactNode }) {
  const { active } = useTabs();
  if (active !== id) return null;
  return <div role="tabpanel">{children}</div>;
};

The usage is exactly what you'd expect:

<Tabs defaultTab="overview">
  <Tabs.List>
    <Tabs.Tab id="overview">Overview</Tabs.Tab>
    <Tabs.Tab id="settings">Settings</Tabs.Tab>
    <Tabs.Tab id="billing">Billing</Tabs.Tab>
  </Tabs.List>

  <Tabs.Panel id="overview"><OverviewContent /></Tabs.Panel>
  <Tabs.Panel id="settings"><SettingsContent /></Tabs.Panel>
  <Tabs.Panel id="billing"><BillingContent /></Tabs.Panel>
</Tabs>

Look, you could build this with a single <Tabs tabs={[...]} panels={[...]} /> component and a data array. Some teams do. But then you can't put an icon next to one tab and a badge next to another without adding more props. And you can't lazy-load just the billing panel without restructuring everything. Compound components give you that flexibility for free because you're working with real JSX children, not data objects.

If you're building UI kits — especially ones in specific visual styles like the glassmorphism components or neobrutalism components available through Empire UI — compound components are basically mandatory for anything interactive. The alternative is prop soup and a changelog full of feat: add footerLeftIcon prop.

Common Mistakes and How to Avoid Them

A few things will catch you. First: don't reach for React.Children.map and cloneElement to pass props to children. You'll see this in older guides from pre-2019. It breaks with fragments, doesn't work with non-direct children, and is just opaque. Context is the right tool here. Always.

Second, memoize your context value if the root re-renders frequently:

const contextValue = useMemo(
  () => ({ isOpen, close: () => setIsOpen(false) }),
  [isOpen]
);

return (
  <ModalContext.Provider value={contextValue}>
    {children}
  </ModalContext.Provider>
);

Without this, every consumer of the context re-renders whenever the root's parent re-renders — even if isOpen didn't change. At 48px modal animation frames that stutter is real. Wrapping the value in useMemo costs you two lines and saves you a perf bug.

Third: expose displayName on your sub-components. React DevTools shows component names in the tree, and Modal.Header as an anonymous function is useless to debug. One line fixes it: Modal.Header.displayName = 'Modal.Header'. For the full picture on hooks like useMemo and useContext, our React hooks complete guide goes deeper on when and why to memoize.

That said, don't over-engineer it. A 3-prop button doesn't need compound components. The pattern pays off when you have a parent component that owns state and multiple children that need to read or write to it. If you're just passing a className and an onClick, you don't need this. Patterns are tools, not rules.

When to Use This Pattern (and When Not To)

Compound components are the right call when: the component has meaningful internal state that multiple children need to share; the composition of the UI varies meaningfully across use cases; you want callers to control layout without forking the component; or you're building something that will live in a shared library for more than one team.

Skip it when: the component has a fixed layout that never changes; it's a leaf node with no children (think <Avatar src={url} size={40} />); or you're prototyping something that might get thrown away. Compounding everything is its own kind of over-engineering.

For design-heavy interactive components — things like animated tab bars, custom dropdowns, multi-step flows — this pattern pairs really well with animation libraries. If you're using Framer Motion to animate panel transitions, check out our Framer Motion advanced guide for how to animate children without losing layout control.

In practice, you'll end up using compound components for maybe 15-20% of your components — the stateful orchestrator ones. The rest are fine as simple props-based components or pure presentational functions. The goal isn't to use this pattern everywhere; it's to reach for it when you feel yourself adding the 8th prop to something that should be composable.

One last thing — if you're curious what well-designed component APIs look like at scale, browse the Empire UI library. Most of the interactive components there follow exactly this pattern: a root that owns state, sub-components that read it, and zero configuration props you didn't ask for.

FAQ

Can I use compound components with server components in Next.js?

The context-based version requires client components since you're using useState and useContext. Mark the root and sub-components with 'use client' and you're fine — the pattern works exactly the same way.

Is this the same as the render props pattern?

Different approach, same goal. Render props thread shared state through a function prop; compound components thread it through context. Context is cleaner to read and compose — render props made more sense before hooks existed.

How do I handle TypeScript autocomplete for sub-components?

Attach sub-components as properties on the root function type. You can define an interface like Modal & { Header: typeof ModalHeader } and cast the export. Most teams just let TypeScript infer from the static property assignments.

What if a sub-component is used outside the root by accident?

Throw in your context hook: if (!ctx) throw new Error('Modal.Header must be used inside <Modal>'). React will surface this as an error boundary catch in dev, which is exactly when you want to catch it — not silently at runtime.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Render Props in React 2026: Dead Pattern or Still Useful?15 Custom React Hooks That Will Save You Hundreds of LinesCompound Component Pattern in React: Context + Sub-ComponentsReact Component API Design: Props, Variants and Compound Patterns