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
headerIconprop. - Different button order? Add
reverseFooterButtonsprop. - No footer for certain cases? Add
showFooterprop. - Custom animation? Add
animationTypeprop.
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 defaultconst ModalContext = createContext(null);
// Custom hook for consuming context with error handlingfunction useModalContext() { const context = useContext(ModalContext); if (!context) { throw new Error('Modal components must be used within Modal'); } return context;}
// Step 2: Parent component provides contextfunction 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 contextModal.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:
- Context for shared state -
openandonCloseare available to all children - Error handling -
useModalContextthrows if used outside Modal - Dot notation -
Modal.Header,Modal.Title, etc. for clean imports - Flexible styling - Each component accepts className
- 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:
// Goodimport { Modal } from './Modal';
// Bad - forces multiple importsimport Modal, { ModalHeader, ModalBody, ModalFooter } from './Modal';Compound vs Prop-Based: A Comparison
| Aspect | Prop-Based | Compound |
|---|---|---|
| Flexibility | Limited to designed variations | Infinite composition |
| API Surface | Grows with each variation | Stays constant |
| Learning Curve | Memorize prop names | Understand component structure |
| TypeScript Support | Complex prop interfaces | Clean, discoverable types |
| Bundle Size | One component | Multiple, tree-shakeable |
| Reordering | Requires new props | Just move JSX |
| Custom Injection | Requires render props | Direct composition |
| Accessibility | Manual implementation | Built 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.
Related Knowledge
- 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