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

Akash JayanAkash Jayan
5 min read

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:

  1. Use more domain-specific naming (Modal vs Dialog)

  2. Preset certain configurations (like max-width)

  3. 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:

  1. Complex UI elements with multiple related parts (modals, tabs, accordions, etc.)

  2. Components with many configuration options that would be unwieldy as props

  3. UI components that benefit from flexible composition

  4. 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.

Repo:https://github.com/Akashjayan1999/Design-pattern

0
Subscribe to my newsletter

Read articles from Akash Jayan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Akash Jayan
Akash Jayan