Compound Design Pattern in React

Harsh Jaiswal
6 min readJan 27, 2024
Photo by Karl Abuid on Unsplash

If you are someone with a bit of an experience in React.js , you might have come across requirements where you had to share some functionality across different components having completely different UI.

For example, consider a very simple Dropdown component -

This component’s main functionality is to show some items on a trigger and then hide them again with another trigger. A typical implementation for this would look like as shown below -

import { HTMLAttributes, useState } from "react";

type Option = {
label: string;
value: string;
};

interface DropdownProps extends HTMLAttributes<HTMLDivElement>{
options: Option[];
}

const Dropdown = (props: DropdownProps) => {
const { options, ...rest } = props;
const [selectedOption, setSelectedOption] = useState<Option | null>(null);
const [isOpen, setIsOpen] = useState(false);

const handleToggle = () => {
setIsOpen(!isOpen);
};

const handleSelect = (option: Option) => {
setSelectedOption(option);
setIsOpen(false);
};

return (
<div {...rest}>
<button
onClick={handleToggle}
type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{selectedOption ? selectedOption.label : "Select an option"}
</button>
{isOpen && (
<ul className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.map((option) => (
<li
key={option.value}
onClick={() => handleSelect(option)}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 cursor-pointer"
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};

export default Dropdown;

Here, the component initially keeps the options hidden and only displays a button. Then using the handleToggle function we toggle the visibility of options by setting the isOpen state as true . Finally once the user selects the option , we again hide the options by setting isOpen state as false through handleSelect function. Pretty standard, right ?

But now let’s say your platform requires a different type of Dropdown component whose UI is supposed to look and feel completely different from the above but its behaviour still has to remain the same.

Hah, so what! I’ll just create another component and re-write the same logic and a new layout, easy peasy!! 😎

But yeah, I am a bit lazy and would have loved it , if there was a way to somehow re-use the existing state management logic so that I could just focus on building the new layout.

This is exactly when we need the Compound Pattern which allows us to share some implicit state across different components.

What are Compound Components ?

When multiple components work together to have a shared state and handle logic together, they are called compound components. The compound component pattern allows you to create components that all work together to perform a task.

SHOW ME THE CODE !!

Now, let’s see this pattern in action. To share the state management logic, we need to create a wrapper component which will use the Context API provided in React. This wrapper component will wrap all our future variants of Dropdowns.

  1. Create a Provider (Wrapper)

This wrapper will hold all the states needed for dropdown functionality.

import { useContext, useState, createContext, PropsWithChildren, Dispatch, SetStateAction, ReactElement } from "react";

type Option = {
label: string;
value: string;
};

interface DropdownContext {
isOpen: boolean;
selectedOption: Option | null;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setSelectedOption: Dispatch<SetStateAction<Option | null>>;
}

const DropdownContext = createContext<DropdownContext | undefined>(undefined);

const DropdownProvider = (props: PropsWithChildren) => {
const { children } = props;
const [selectedOption, setSelectedOption] = useState<Option | null>(null);
const [isOpen, setIsOpen] = useState(false);

return <DropdownContext.Provider value={{ isOpen, selectedOption, setIsOpen, setSelectedOption }}>{children}</DropdownContext.Provider>;
};

2. Create a wrapper for Toggling Options

Here, we will consume the states defined in the above Provider through useContext method provided by React.

const OptionToggler = ({
children,
}: {
children: ({ selectedOption, toggleOptions }: { selectedOption: Option | null; toggleOptions?: () => void }) => ReactElement;
}) => {
const context = useContext(DropdownContext);
if (context) {
const { setIsOpen, selectedOption, isOpen } = context;

const toggleOptions = () => {
setIsOpen(!isOpen);
};

return children({ selectedOption, toggleOptions });
} else {
throw new Error("OptionToggler can only be used withn a DropdownProvider");
}
};

3. Create a wrapper for displaying Dropdown options

const DropdownOptions = ({ children }: { children: ({ handleSelect }: { handleSelect: (option: Option) => void }) => ReactElement }) => {
const context = useContext(DropdownContext);
if (context) {
const { isOpen, setIsOpen, setSelectedOption } = context;

const handleSelect = (option: Option) => {
setSelectedOption(option);
setIsOpen(false);
};

return isOpen ? children({ handleSelect }) : null;
} else {
throw new Error("DropdownOptions can only be used withn a DropdownProvider");
}
};

Notice, how we are rendering children components in the last two wrappers. This is something atypical and not how we are generally used to render child components. Here, children prop is actually a function that can accept any parameters but has to return a ReactElement . This approach gives us more flexibility while rendering our custom layout, you’ll notice how.

Finally the entire refactored component now looks like as shown below —

import { useContext, useState, createContext, PropsWithChildren, Dispatch, SetStateAction, ReactElement } from "react";

type Option = {
label: string;
value: string;
};

interface DropdownContext {
isOpen: boolean;
selectedOption: Option | null;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setSelectedOption: Dispatch<SetStateAction<Option | null>>;
}

const DropdownContext = createContext<DropdownContext | undefined>(undefined);

const DropdownProvider = (props: PropsWithChildren) => {
const { children } = props;
const [selectedOption, setSelectedOption] = useState<Option | null>(null);
const [isOpen, setIsOpen] = useState(false);

return <DropdownContext.Provider value={{ isOpen, selectedOption, setIsOpen, setSelectedOption }}>{children}</DropdownContext.Provider>;
};

const DropdownOptions = ({ children }: { children: ({ handleSelect }: { handleSelect: (option: Option) => void }) => ReactElement }) => {
const context = useContext(DropdownContext);
if (context) {
const { isOpen, setIsOpen, setSelectedOption } = context;

const handleSelect = (option: Option) => {
setSelectedOption(option);
setIsOpen(false);
};

return isOpen ? children({ handleSelect }) : null;
} else {
throw new Error("DropdownOptions can only be used withn a DropdownProvider");
}
};

const OptionToggler = ({
children,
}: {
children: ({ selectedOption, toggleOptions }: { selectedOption: Option | null; toggleOptions?: () => void }) => ReactElement;
}) => {
const context = useContext(DropdownContext);
if (context) {
const { setIsOpen, selectedOption, isOpen } = context;

const toggleOptions = () => {
setIsOpen(!isOpen);
};

return children({ selectedOption, toggleOptions });
} else {
throw new Error("OptionToggler can only be used withn a DropdownProvider");
}
};

DropdownProvider.Toggler = OptionToggler;
DropdownProvider.Options = DropdownOptions;

export default DropdownProvider;

How can I use this new wrapper component ?

Now, to render a Dropdown with custom layouts without re-writing your state management logic, you can use it as shown below -

type Option = {
label: string;
value: string;
};

interface MyCoolNewDropdownProps {
options: Option[]
}

const MyCoolNewDropdown = (props: MyCoolNewDropdownProps) => {
const {options} = props;

return (
<DropdownProvider>
<div className="relative w-6/12">
<DropdownProvider.Toggler>
{({ selectedOption, toggleOptions }) => (
<button
onClick={toggleOptions}
type="button"
className="inline-flex justify-center w-full rounded-full border border-gray-300 shadow-sm px-4 py-2 bg-black-100 text-sm font-medium text-white focus:outline-none focus:ring-offset-2 focus:outline-black"
>
{selectedOption ? `You have selected - ${selectedOption.label}` : "Select an option"}
<img
width={20}
className="ml-auto"
src={getPublicAssetUrl("/icons/outline/chevron-down.svg")}
style={{ filter: "brightness(0) saturate(100%) invert(98%) sepia(5%) saturate(80%) hue-rotate(254deg) brightness(115%) contrast(100%)" }}
/>
</button>
)}
</DropdownProvider.Toggler>

<DropdownProvider.Options>
{({ handleSelect }) => (
<ul className="origin-top-right absolute w-full right-0 mt-2 rounded-2xl shadow-lg bg-black ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.map((option) => (
<li
key={option.value}
onClick={() => handleSelect(option)}
className="block rounded-2xl py-4 text-sm text-white hover:bg-gray-700 text-center cursor-pointer"
>
{option.label}
</li>
))}
</ul>
)}
</DropdownProvider.Options>
</div>
</DropdownProvider>
);
};

Notice, how we only had to write the layout for our new Dropdown component this time without handling any state management logic by wrapping everything inside DropdownProvider and decoupling the layout with functionality. Now you can go as crazy as possible with the layout without having to worry about the functionality.

Check out the output for the new dropdown —

Closing in

In summary, this pattern offers a robust solution for building modular and reusable components having a clear distinction between layout and functionality. Would highly recommend you all to embrace this pattern and start building high quality React apps.

Also , a big thanks to https://betterprogramming.pub/compound-component-design-pattern-in-react-34b50e32dea0 which helped me understand the pattern clearly and inspired me to write this article.

Cheers to all , wishing you a wonderful day ahead !!

--

--