DEV Community

Anuj Upadhyaya
Anuj Upadhyaya

Posted on

Mastering Compound Components: Building a Flexible, Accessible Dialog

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.

Diagram showing a Parent Component sharing state with nested Child Components via Context API
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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" and aria-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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)