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.
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">×</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
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.
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.
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.
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.