Mastering Compound Components: Building a Flexible, Accessible Dialog
Building a component library or a polished personal portfolio requires a balance between flexibility and developer ergonomics. You want components that developers can compose like Lego blocks without worrying about wiring up internal state manually.
During my recent portfolio refresh, I revisited the Compound Component pattern. It is one of React’s most powerful advanced patterns, enabling related components to share implicit state and communicate through an explicit parent-child relationship.
In this post, I’ll break down how I used this pattern to build a robust, accessible Dialog component.
What are Compound Components?
Think of the native HTML <select> and <option> tags [5-7]. The <select> manages the state, and the <option> elements act as configuration [5, 6, 8]. You don't pass props from the select to every option; they just "work" together.
In React, the Compound Component pattern encloses the state and behavior of a group of components while giving rendering control back to the user [9, 10]. This creates declarative APIs where the structure of your code matches the structure of your UI.

Figure 1: The Parent (Root) holds shared state, while Children consume it implicitly.
Why use this pattern?
- Avoid Prop Drilling: No need to pass state manually through multiple levels.
- Flexibility: Users can reorder or style components freely [10, 11, 14].
- Separation of Concerns: The parent handles the logic; children stay "dumb" and focused on rendering [14, 16].
Building the Dialog Component
We will use the Context API to share state implicitly [15, 17, 18]. This is the modern recommended approach as it allows children to be accessible at all levels of the component tree, not just as direct children.
Step 1: The Context and Root
The root component houses the shared state—in this case, whether the dialog is open.
import React, { createContext, useContext, useState, useMemo } from 'react';
// 1. Create the Context
const DialogContext = createContext(undefined);
// 2. Create the Root Component
export function Dialog({ children }) {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
const close = () => setIsOpen(false);
// Memoize the context value to prevent unnecessary re-renders [24, 25]
const value = useMemo(() => ({ isOpen, toggle, close }), [isOpen]);
return (
<DialogContext.Provider value={value}>
{children}
</DialogContext.Provider>
);
}
Step 2: Consumer Hook
To ensure our components are used correctly, we create a hook that throws a helpful error if a child is rendered outside the Dialog root.
function useDialogContext() {
const context = useContext(DialogContext);
if (!context) {
// Helpful error message for developers [27]
throw new Error('Dialog sub-components must be wrapped in <Dialog />');
}
return context;
}
Step 3: Sub-components (Trigger and Content)
We attach these components to the Dialog object for a clean, namespaced API.
// 3. The Trigger
Dialog.Trigger = function DialogTrigger({ children }) {
const { toggle } = useDialogContext();
return <button onClick={toggle}>{children}</button>;
};
// 4. The Content
Dialog.Content = function DialogContent({ children }) {
const { isOpen, close } = useDialogContext();
if (!isOpen) return null; // Only render content when open [31, 32]
return (
<div role="dialog" aria-modal="true" className="dialog-overlay">
<div className="dialog-content">
{children}
<button onClick={close} aria-label="Close dialog">×</button>
</div>
</div>
);
};
Making it Accessible by Design
Accessibility (A11Y) shouldn't be an extra step; it should be built into your components. By using compound components, you can handle ARIA attributes and keyboard interactions behind the scenes.
For a Dialog, this means:
- Adding
role="dialog"andaria-modal="true". - Managing focus and linking triggers to content using unique IDs.
- Ensuring screen readers announce selection and state correctly.
The Result: A Declarative API
Because we used this pattern, the end-user API is incredibly clean. You can move the Trigger anywhere in relation to the Content and it will still work flawlessly.
function App() {
return (
<Dialog>
<Dialog.Trigger>View Project Details</Dialog.Trigger>
<Dialog.Content>
<h2>Compound Pattern Project</h2>
<p>This implementation uses React Context and Hooks.</p>
</Dialog.Content>
</Dialog>
);
}
Conclusion
Compound components are a superpower for design systems. They provide an intuitive API for developers while keeping complex state and behavior encapsulated under the hood. Mastering this pattern ensures your components are not just functional, but also intuitive and inevitable.
How are you using advanced patterns in your latest projects? Let’s discuss in the comments!
Top comments (0)