Skip to content

What is the Compound Component Pattern in React?

I had 47 props on a Modal component. FORTY-SEVEN. Every time a designer wanted a new variation, I added another prop. showCloseButton, closeButtonPosition, headerVariant, footerAlign, onFooterButtonClick… the list kept growing.

Then someone asked if they could render the footer above the content for a specific use case. I stared at my prop soup and realized: this architecture was fundamentally broken.

That’s when I found compound components.

The Problem: Prop Configuration Hell

Traditional component APIs rely on props for customization. For simple components, this works fine. But as flexibility requirements grow, props multiply uncontrollably.

Here’s where I started:

// My prop-heavy Modal - each variation required more props
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Confirm Action"
description="Are you sure?"
showCloseButton={true}
closeButtonPosition="top-right"
headerVariant="primary"
footerAlign="right"
footerButtons={[
{ label: 'Cancel', onClick: handleClose, variant: 'secondary' },
{ label: 'Confirm', onClick: handleConfirm, variant: 'primary' }
]}
contentClassName="p-6"
headerClassName="bg-gray-100"
footerClassName="border-t"
// ... 35 more props I'm not showing
>
Modal content here
</Modal>

Every time someone needed:

  • A custom icon in the header? Add headerIcon prop.
  • Different button order? Add reverseFooterButtons prop.
  • No footer for certain cases? Add showFooter prop.
  • Custom animation? Add animationType prop.

This approach has fatal flaws:

Prop-Based API Problems:
+---------------------------+----------------------------------------+
| Problem | Consequence |
+---------------------------+----------------------------------------+
| Props multiply endlessly | Component API becomes unlearnably complex |
| Order is fixed | Can't reorder header/footer/content |
| Composition is limited | Can't inject custom elements |
| Boolean flags accumulate | `showHeader`, `showFooter`, `showIcon` |
| Styling is rigid | className props for every section |
| Refs are blocked | Can't access internal DOM nodes |
+---------------------------+----------------------------------------+

I needed a different approach. One that offered flexibility without prop explosion.

The Solution: Compound Components

Compound components are two or more components that work together, sharing implicit state through React Context. Think of HTML’s <select> and <option>:

<!-- The browser knows <option> belongs to <select> -->
<!-- No props needed to connect them -->
<select>
<option value="1">One</option>
<option value="2">Two</option>
</select>

The select provides context. The option consumes it. They’re composed through nesting, not configuration.

How It Works: The Three-Step Pattern

Step 1: Create Context for Shared State
+-------------------------------------------+
| const ModalContext = createContext(); |
| |
| Parent creates the context, |
| children consume it |
+-------------------------------------------+
Step 2: Parent Provides Context
+-------------------------------------------+
| function Modal({ children }) { |
| const [isOpen, setIsOpen] = useState();|
| return ( |
| <ModalContext.Provider value={{...}}>|
| <div className="modal"> |
| {children} |
| </div> |
| </ModalContext.Provider> |
| ); |
| } |
+-------------------------------------------+
Step 3: Children Consume Context
+-------------------------------------------+
| function ModalHeader({ children }) { |
| const { isOpen } = useModalContext(); |
| return <div className="header">...</div>|
| } |
| |
| Modal.Header = ModalHeader; |
+-------------------------------------------+

The result: users compose components naturally, and state flows invisibly.

From Props to Compound: A Real Migration

I rewrote my Modal using compound components. Here’s the transformation:

Before (Prop-Based)

<Modal
isOpen={isOpen}
onClose={handleClose}
title="Delete Account"
description="This action cannot be undone"
showCloseButton
footerButtons={[
{ label: 'Cancel', onClick: handleClose },
{ label: 'Delete', onClick: handleDelete, variant: 'danger' }
]}
>
<p>Are you sure you want to delete your account?</p>
</Modal>

After (Compound)

<Modal open={isOpen} onClose={handleClose}>
<Modal.Header>
<Modal.Title>Delete Account</Modal.Title>
<Modal.Description>This action cannot be undone</Modal.Description>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to delete your account?</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>Cancel</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</Modal.Footer>
</Modal>

Same functionality. But now I can:

// Reorder sections
<Modal>
<Modal.Footer>Actions first</Modal.Footer>
<Modal.Body>Content second</Modal.Body>
<Modal.Header>Header last</Modal.Header>
</Modal>
// Omit sections entirely
<Modal>
<Modal.Body>Just content, no header or footer</Modal.Body>
</Modal>
// Inject custom elements
<Modal>
<Modal.Header>
<Badge color="danger">Warning</Badge>
<Modal.Title>Delete Account</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert type="error">This is irreversible!</Alert>
<p>Are you sure?</p>
</Modal.Body>
</Modal>
// Style individual pieces
<Modal>
<Modal.Header className="bg-red-500 text-white">
<Modal.Title>Delete Account</Modal.Title>
</Modal.Header>
</Modal>

Zero new props. Infinite flexibility.

The Implementation

Here’s a production-ready compound component implementation:

import { createContext, useContext, useState } from 'react';
// Step 1: Create context with a meaningful default
const ModalContext = createContext(null);
// Custom hook for consuming context with error handling
function useModalContext() {
const context = useContext(ModalContext);
if (!context) {
throw new Error('Modal components must be used within Modal');
}
return context;
}
// Step 2: Parent component provides context
function Modal({ open, onClose, children, className }) {
const [internalValue, setInternalValue] = useState('');
const contextValue = {
open,
onClose,
internalValue,
setInternalValue,
};
if (!open) return null;
return (
<ModalContext.Provider value={contextValue}>
<div
className={`modal-overlay ${className || ''}`}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-content" role="dialog" aria-modal="true">
{children}
</div>
</div>
</ModalContext.Provider>
);
}
// Step 3: Child components consume context
Modal.Header = function ModalHeader({ children, className }) {
return (
<div className={`modal-header ${className || ''}`}>
{children}
</div>
);
};
Modal.Title = function ModalTitle({ children, className }) {
return (
<h2 className={`modal-title ${className || ''}`}>
{children}
</h2>
);
};
Modal.Description = function ModalDescription({ children, className }) {
return (
<p className={`modal-description ${className || ''}`}>
{children}
</p>
);
};
Modal.Body = function ModalBody({ children, className }) {
return (
<div className={`modal-body ${className || ''}`}>
{children}
</div>
);
};
Modal.Footer = function ModalFooter({ children, className }) {
return (
<div className={`modal-footer ${className || ''}`}>
{children}
</div>
);
};
Modal.CloseButton = function ModalCloseButton({ className }) {
const { onClose } = useModalContext();
return (
<button
onClick={onClose}
className={`modal-close ${className || ''}`}
aria-label="Close modal"
>
<XIcon />
</button>
);
};
export { Modal };

Key implementation details:

  1. Context for shared state - open and onClose are available to all children
  2. Error handling - useModalContext throws if used outside Modal
  3. Dot notation - Modal.Header, Modal.Title, etc. for clean imports
  4. Flexible styling - Each component accepts className
  5. Accessibility built-in - role="dialog", aria-modal, close button

HeroUI v3: Compound Components at Scale

HeroUI’s v3 rewrite demonstrates compound components in production. Their documentation states:

“Compound Architecture: Components now use compound patterns where every internal piece is a real element you can style, move, swap, or remove.”

Card Component

<Card variant="default">
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Description>Card description text</Card.Description>
</Card.Header>
<Card.Content>Main content goes here</Card.Content>
<Card.Footer>Footer actions</Card.Footer>
</Card>

Each piece (Card.Header, Card.Title, etc.) is a real component you can:

  • Style individually with className
  • Move to different positions
  • Remove entirely
  • Replace with custom implementations

Table Component

<Table>
<Table.ScrollContainer>
<Table.Content aria-label="Users table">
<Table.Header>
<Table.Column allowsSorting>Name</Table.Column>
<Table.Column>Role</Table.Column>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Kate Moore</Table.Cell>
<Table.Cell>CEO</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
<Table.Footer>Pagination controls</Table.Footer>
</Table>

Notice Table.ScrollContainer - you can omit it if you don’t want scrolling. Table.Footer can be repositioned or removed. Every piece is addressable.

React Aria DatePicker (What HeroUI Builds On)

<DatePicker>
<Label>Date of birth</Label>
<Group>
<DateInput />
<Button>Calendar</Button>
</Group>
<Popover>
<Calendar>
<CalendarGrid />
</Calendar>
</Popover>
</DatePicker>

React Aria Components take this further. Want a custom calendar layout? Swap CalendarGrid for your own. Need a different trigger? Replace Button. The pattern scales to complex components.

When Compound Components Shine

Compound Component Decision Matrix:
+----------------------------------+----------------------------------+
| USE COMPOUND WHEN | AVOID COMPOUND WHEN |
+----------------------------------+----------------------------------+
| Building UI libraries | Simple, single components |
| Clear parent-child relationship | No meaningful composition exists |
| Users need customization | Performance is critical and |
| Accessibility matters | context overhead is measurable |
| State should be encapsulated | Team unfamiliar with Context |
+----------------------------------+----------------------------------+

Ideal Use Cases

1. Complex interactive components

Modals, Dropdowns, Tabs, Accordions, Tables - anything with multiple related pieces that share state.

2. Design system primitives

When building a component library, compound patterns let users adapt components to their needs without requesting new props.

3. Accessibility-first components

React Aria’s compound components bake in keyboard navigation, ARIA attributes, and focus management. Users get accessibility for free while maintaining composition flexibility.

Anti-Patterns to Avoid

1. Over-compounding simple components

// Don't do this for a simple button
<Button>
<Button.Text>Click me</Button.Text>
<Button.Icon icon={<Arrow />} />
</Button>
// A simple prop is better
<Button icon={<Arrow />}>Click me</Button>

2. Exposing too much internal state

Compound components should hide implementation details, not expose every piece of state.

3. Ignoring the single import principle

Users should import one thing:

// Good
import { Modal } from './Modal';
// Bad - forces multiple imports
import Modal, { ModalHeader, ModalBody, ModalFooter } from './Modal';

Compound vs Prop-Based: A Comparison

AspectProp-BasedCompound
FlexibilityLimited to designed variationsInfinite composition
API SurfaceGrows with each variationStays constant
Learning CurveMemorize prop namesUnderstand component structure
TypeScript SupportComplex prop interfacesClean, discoverable types
Bundle SizeOne componentMultiple, tree-shakeable
ReorderingRequires new propsJust move JSX
Custom InjectionRequires render propsDirect composition
AccessibilityManual implementationBuilt into primitives

The Trade-off: Verbosity vs Control

Compound components require more keystrokes upfront:

// Prop-based (shorter)
<Modal title="Confirm" footer={<Button>OK</Button>}>Content</Modal>
// Compound (more verbose)
<Modal>
<Modal.Header><Modal.Title>Confirm</Modal.Title></Modal.Header>
<Modal.Body>Content</Modal.Body>
<Modal.Footer><Button>OK</Button></Modal.Footer>
</Modal>

But the compound version scales. When you need:

  • Two buttons in the footer? Just add them.
  • A badge next to the title? Insert it.
  • Different styling on mobile? Apply conditional classes.
  • Custom animation on the body? Wrap it.

The prop-based version would need: footerButtonCount, titleBadge, mobileClassName, bodyAnimationType… and you’re back to prop soup.

  • React Context API - The mechanism that enables implicit state sharing
  • Control Props Pattern - Alternative approach where state is externalized
  • Render Props - Another composition technique, often used with compound components
  • Slots Pattern - Similar concept in Vue and other frameworks
  • Headless UI - Libraries like Radix UI that popularized this approach

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments