© 2024 Jeremiah Yee
24 Nov 2024
Previously, I wrote about how to differentiate and write controlled and uncontrolled components. In this installment, we’ll explore how composition and the compound component pattern can help you create powerful, more flexible, and reusable components. These patterns empower developers to avoid bloated props and give them greater control over the layout and behavior of components.
We’ll start by considering a common scenario where you’re building a custom Dropdown
component for a UI library. A straightforward but flawed implementation might look like this:
const Dropdown = ({ label, options, selectedValue, onChange, isOpen, toggle }) => {
if (!isOpen) return null;
return (
<div className="dropdown">
<button onClick={toggle}>{label}</button>
<ul>
{options.map((option) => (
<li key={option.value}>
<button
onClick={() => onChange(option.value)}
className={option.value === selectedValue ? 'active' : ''}
>
{option.label}
</button>
</li>
))}
</ul>
</div>
);
};
export default Dropdown;
import Dropdown from "./Dropdown";
function App() {
const [selectedValue, setSelectedValue] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const options = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
];
return (
<div>
<Dropdown
label="Select Fruit"
options={options}
selectedValue={selectedValue}
onChange={setSelectedValue}
isOpen={isOpen}
toggle={() => setIsOpen(!isOpen)}
/>
</div>
);
}
export default App;
When a component has too many props, like the Dropdown
example above, it becomes harder to maintain and extend. Each new feature or behavior will likely require a new prop, which bloats the component’s interface.
Imagine you want to add a feature to disable options. To support this, you would add a disabledOptions
prop:
const Dropdown = ({ label, options, selectedValue, onChange, isOpen, toggle, disabledOptions }) => {
if (!isOpen) return null;
return (
<div className="dropdown">
<button onClick={toggle}>{label}</button>
<ul>
{options.map((option) => (
<li key={option.value}>
<button
onClick={() => onChange(option.value)}
className={option.value === selectedValue ? 'active' : ''}
disabled={disabledOptions.includes(option.value)}
>
{option.label}
</button>
</li>
))}
</ul>
</div>
);
};
The more features you add, the more props you need, and over time, this leads to an unwieldy and hard-to-understand component interface. Now, every time you maintain or update the component, you must handle the added complexity of these props.
In the current Dropdown
implementation, the structure and behavior are tightly coupled inside the component. If you want to customize any part of the dropdown (like the button
or ul
tags), you’re forced to modify the component directly.
What if you wanted to add a header inside the dropdown or change the appearance of the dropdown based on user preference?
<Dropdown
label="Select Fruit"
options={options}
// How would you add a custom header inside the dropdown?
/>
There’s no way to modify the internal layout without editing the component itself, making it less flexible for different use cases.
Since the dropdown structure is rigid, adding new elements or changing behaviors is difficult. If a developer wants to group the options or insert a custom footer, they’d need to either modify the existing component or recreate it from scratch.
Imagine you need to group your dropdown options under categories. With the current setup, there’s no easy way to do this:
// You'd need to add extra logic to support groups within Dropdown.js
const groupedOptions = {
fruits: [{ value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }],
vegetables: [{ value: 'carrot', label: 'Carrot' }, { value: 'potato', label: 'Potato' }]
};
<Dropdown
label="Select Item"
options={groupedOptions}
// But how would you show the group headings?
/>
The Dropdown
component would need additional logic to handle this grouping, further complicating the structure. This leads to a rigid component that’s difficult to extend without breaking the current implementation.
To overcome these limitations, we’ll refactor the Dropdown
component using composition and the compound component pattern. This approach will provide developers with greater flexibility and control while keeping the component’s logic encapsulated.
Composition in React allows you to build complex components by combining simpler, more focused ones. Instead of passing everything through props, you can use children
to let developers compose components according to their needs.
import React from 'react';
const Dropdown = ({ isOpen, children }) => {
return isOpen ? <div className="dropdown">{children}</div> : null;
};
export default Dropdown;
import React, { useState } from 'react';
import Dropdown from './Dropdown';
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Select Fruit</button>
<Dropdown isOpen={isOpen}>
<ul>
<li>
<button onClick={() => alert('Apple selected')}>Apple</button>
</li>
<li>
<button onClick={() => alert('Banana selected')}>Banana</button>
</li>
{/* More options */}
</ul>
</Dropdown>
</div>
);
}
export default App;
Now we have greater flexibility. Developers can structure and style the dropdown content as they see fit, inserting custom elements or grouping options. The component also requires fewer props, making it easier to understand and use.
While composition improves flexibility, the compound component pattern takes it a step further by providing a set of components that work together seamlessly. These components share state implicitly, allowing developers to build complex interfaces without prop drilling or excessive state management.
First, our end goal is to have different components that compound into a Dropdown
. So we might think of having a separate component for the Dropdown’s options, for its menu, and for its toggle. Since multiple components are sharing state under the Dropdown
, we can begin by making use of React’s Context API to provide the state functionality.
import React, { useState, createContext } from 'react';
const DropdownContext = createContext();
const Dropdown = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownContext.Provider value={{ isOpen, setIsOpen }}>
<div className="dropdown">{children}</div>
</DropdownContext.Provider>
);
};
export default Dropdown;
Now we have a container that can manage whether the dropdown is open or closed.
Next, we might need a Toggle
component that will handle opening and closing the dropdown.
import React, { useContext } from 'react';
import { DropdownContext } from './Dropdown';
const Toggle = ({ children }) => {
const { isOpen, setIsOpen } = useContext(DropdownContext);
return (
<button onClick={() => setIsOpen(!isOpen)}>
{children} {isOpen ? '▲' : '▼'}
</button>
);
};
export default Toggle;
The Menu
component will then show or hide based on the dropdown’s open state.
import React, { useContext } from 'react';
import { DropdownContext } from './Dropdown';
const Menu = ({ children }) => {
const { isOpen } = useContext(DropdownContext);
return isOpen ? <ul className="dropdown-menu">{children}</ul> : null;
};
export default Menu;
Afterwards, each dropdown option will be represented by the Item
component. This will trigger the action passed via the onSelect
prop, allowing us to handle the selection of an option.
import React from 'react';
const Item = ({ children, onSelect }) => {
return (
<li className="dropdown-item">
<button onClick={onSelect}>{children}</button>
</li>
);
};
export default Item;
To make it easier for developers to use, we attach all the subcomponents (Toggle
, Menu
, Item
) to the main Dropdown
component. This packages everything into a clean API that emphasizes their relationship.
Dropdown.Toggle = Toggle;
Dropdown.Menu = Menu;
Dropdown.Item = Item;
export default Dropdown;
import React, { useState, createContext, useContext } from 'react';
const DropdownContext = createContext();
const Dropdown = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownContext.Provider value={{ isOpen, setIsOpen }}>
<div className="dropdown">{children}</div>
</DropdownContext.Provider>
);
};
const Toggle = ({ children }) => {
const { isOpen, setIsOpen } = useContext(DropdownContext);
return (
<button onClick={() => setIsOpen(!isOpen)}>
{children} {isOpen ? '▲' : '▼'}
</button>
);
};
const Menu = ({ children }) => {
const { isOpen } = useContext(DropdownContext);
return isOpen ? <ul className="dropdown-menu">{children}</ul> : null;
};
const Item = ({ children, onSelect }) => {
return (
<li className="dropdown-item">
<button onClick={onSelect}>{children}</button>
</li>
);
};
Dropdown.Toggle = Toggle;
Dropdown.Menu = Menu;
Dropdown.Item = Item;
export default Dropdown;
With everything set up, using the dropdown becomes straightforward. Developers can compose the dropdown with a Toggle
, Menu
, and multiple Item
components.
import React from 'react';
import Dropdown from './Dropdown';
function App() {
const handleSelect = (value) => {
alert(`${value} selected`);
};
return (
<div>
<Dropdown>
<Dropdown.Toggle>Select Fruit</Dropdown.Toggle>
<Dropdown.Menu>
{/* You could put a header here if you'd like */}
<Dropdown.Item onSelect={() => handleSelect('Apple')}>Apple</Dropdown.Item>
<Dropdown.Item onSelect={() => handleSelect('Banana')}>Banana</Dropdown.Item>
{/* More items */}
</Dropdown.Menu>
</Dropdown>
</div>
);
}
export default App;
Composition is a powerful tool and fits in with the mental model of React. Remember, the users of your components are developers. So as far as possible, control should be given to them. Strike a thin balance between inversion of control and internal functionality.
This approach empowers developers to control the component’s structure and behavior without being constrained by a prop-heavy interface.
Here’s a quick way to remember the takeaways. I will call it the 3Cs:
We can take this pattern a step further by implementing a fully headless UI. Sometimes this may not be applicable as we want to stick to a certain styling, which is why this is in the bonus section.
Headless components provide functionality without enforcing a specific UI structure, giving developers maximum flexibility for styling and layout. They focus purely on behavior, leaving the presentation entirely to you.
We’ll refactor our dropdown to use a headless approach where we provide only the logic, allowing full control over the UI.
useDropdown
) that manages the dropdown state and behavior, like opening and closing.
import { useState } from 'react';
export const useDropdown = () => {
const [isOpen, setIsOpen] = useState(false);
const toggleDropdown = () => setIsOpen((prev) => !prev);
const closeDropdown = () => setIsOpen(false);
return {
isOpen,
toggleDropdown,
closeDropdown,
};
};
Dropdown
component then provides state and behavior, but the UI is defined by the user.
import { useDropdown } from './useDropdown';
const Dropdown = ({ children }) => {
const dropdown = useDropdown();
return children(dropdown);
};
export default Dropdown;
Dropdown
component handles the logic.
import Dropdown from './Dropdown';
function App() {
return (
<Dropdown>
{({ isOpen, toggleDropdown, closeDropdown }) => (
<div>
<button onClick={toggleDropdown}>
Select Fruit {isOpen ? '▲' : '▼'}
</button>
{isOpen && (
<ul>
<li>
<button onClick={() => { alert('Apple selected'); closeDropdown(); }}>Apple</button>
</li>
<li>
<button onClick={() => { alert('Banana selected'); closeDropdown(); }}>Banana</button>
</li>
</ul>
)}
</div>
)}
</Dropdown>
);
}
export default App;
This approach is commonly used in libraries such as:
Back to blog