The Compound Design Pattern in React: Creating Flexible and Intuitive UI Components


In the world of React development, creating components that are both flexible and intuitive to use is a constant challenge. The Compound Component pattern has emerged as an elegant solution to this problem, allowing developers to create component APIs that are both powerful and natural to work with. In this blog post, we'll explore how this pattern works using a practical example built with shadcn/ui components.
Understanding the Compound Component Pattern
The Compound Component pattern is a design approach where multiple components work together to form a cohesive unit of functionality. Instead of creating a single complex component with numerous props, we create a parent component that manages the shared state and several child components that handle specific aspects of the UI.
This pattern mirrors HTML's natural composition model (think <select\> and <option\> elements) and leads to more declarative, readable code.
A Practical Example: Modal Dialog Component
Let's examine a real-world implementation of the Compound Component pattern using shadcn/ui's Dialog components:
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
type ModalProps = {
children: React.ReactNode;
};
type ModalComposition = React.FC<ModalProps> & {
Trigger: React.FC<{ children: React.ReactNode }>;
Content: React.FC<{ children: React.ReactNode }>;
Header: React.FC<{ children: React.ReactNode }>;
Title: React.FC<{ children: React.ReactNode }>;
Description: React.FC<{ children: React.ReactNode }>;
}
const Modal:ModalComposition = ({ children }) => {
return (
<Dialog>
{children}
</Dialog>
)
}
Modal.Trigger = ({children})=>{
return <DialogTrigger >{children}</DialogTrigger>
}
Modal.Content = ({ children }) => {
return <DialogContent className="sm:max-w-[425px] ">{children}</DialogContent>
}
Modal.Header = ({ children }) => {
return <DialogHeader>{children}</DialogHeader>;
};
Modal.Title = ({ children }) => {
return <DialogTitle>{children}</DialogTitle>;
};
Modal.Description = ({ children }) => {
return <DialogDescription>{children}</DialogDescription>;
};
export default Modal
This implementation creates a Modal component with five sub-components:
Modal.Trigger : Opens the modal
Modal.Content : Contains the modal content
Modal.Header : Groups the title and description
Modal.Title : Displays the modal title
Modal.Description : Shows additional descriptive text
How It's Used
import React from 'react'
import { Button } from './ui/button';
import Modal from "@/components/ui/modal";
const CompoundComponentModal = () => {
return (
<Modal>
<Modal.Trigger>
Open
</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Are you absolutely sure?</Modal.Title>
<Modal.Description>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</Modal.Description>
</Modal.Header>
</Modal.Content>
</Modal>
)
}
export default CompoundComponentModal
This code is very readable and easy to understand. Even someone who doesn't know the component can figure out what's happening just by looking at the JSX structure. We can also add or remove subcomponents as needed.
Key Benefits of the Compound Component Pattern
1. Natural, Declarative API
The most obvious benefit is how natural the API feels. The component usage resembles HTML's inherent composability, making it intuitive for developers to understand and use.
2. Flexible Component Structure
Developers can arrange the child components however they want, adding, removing, or rearranging them as needed:
<Modal>
<Modal.Trigger>
<Button variant="destructive">Delete Account</Button>
</Modal.Trigger>
<Modal.Content>
{/* Custom layout without using Header */}
<Modal.Title className="text-red-500">Warning: Destructive Action</Modal.Title>
<div className="py-4">
<Modal.Description>
Your account will be permanently deleted.
</Modal.Description>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button variant="destructive">Confirm Delete</Button>
</div>
</div>
</Modal.Content>
</Modal>
3. Implicit State Sharing
The parent component manages state and behavior, sharing them implicitly with children without requiring prop drilling.
4. Progressive Disclosure of Complexity
Simple use cases remain simple while complex customizations are possible when needed.
How It Works Behind the Scenes
In our example, we're using shadcn/ui's Dialog components as the foundation. The Dialog component likely uses React Context internally to share state between its children.
Our Modal component simply wraps these Dialog components with a more intuitive, application-specific API. This creates a nice abstraction layer that lets us:
Use more domain-specific naming (Modal vs Dialog)
Preset certain configurations (like max-width)
Add application-specific extensions later if needed
Implementation Strategies
There are several ways to implement the Compound Component pattern in React:
1. Direct Sub-Component Assignment (as shown in our example)
const Modal = ({ children }) => {
return <Dialog>{children}</Dialog>;
};
Modal.Trigger = ({ children }) => {
return <DialogTrigger>{children}</DialogTrigger>;
};
This is the simplest approach and works well for most cases.
2. Using React Context
For more complex cases where state needs to be shared more deeply:
const ModalContext = React.createContext();
const Modal = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<ModalContext.Provider value={{ isOpen, setIsOpen }}>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children}
</Dialog>
</ModalContext.Provider>
);
};
Modal.Trigger = ({ children }) => {
const { setIsOpen } = useContext(ModalContext);
return (
<button onClick={() => setIsOpen(true)}>
{children}
</button>
);
};
3. Using React.Children.map
For cases where you need to inject props into child components:
const Tabs = ({ children, activeTab, onChange }) => {
return (
<div className="tabs">
{React.Children.map(children, (child, index) => {
return React.cloneElement(child, {
isActive: index === activeTab,
onActivate: () => onChange(index)
});
})}
</div>
);
};
Best Practices for Compound Components
1. Keep the API Intuitive
Name your components in a way that makes their purpose clear. The goal is to create an API that feels natural and self-documenting.
2. Use TypeScript for Better Developer Experience
TypeScript can help document the component structure and catch errors
3. Provide Sensible Defaults
Make sure your components work well with minimal configuration but allow for customization when needed.
4. Document Component Relationships
Create clear documentation showing how components should be used together.
When to Use Compound Components
The Compound Component pattern is particularly useful for:
Complex UI elements with multiple related parts (modals, tabs, accordions, etc.)
Components with many configuration options that would be unwieldy as props
UI components that benefit from flexible composition
When you want to create a domain-specific abstraction over existing components
Conclusion
The Compound Component pattern is a powerful tool in your React development arsenal. By breaking complex UI elements into smaller, focused components that work together, you create more intuitive, flexible, and maintainable code.
Our Modal example built on shadcn/ui's Dialog components demonstrates how easy it is to implement this pattern. The result is a component API that feels natural to use, is easy to understand at a glance, and provides flexibility without sacrificing simplicity.
Next time you are building a complex UI component, consider whether the Compound Component pattern might be the right approach. Your future self and other developers will appreciate the intuitive and readable code it creates.
Subscribe to my newsletter
Read articles from Akash Jayan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
