Compound Pattern
Create multiple components that work together to perform a single task
Overview
With the Compound Pattern, we can create multiple components that work together to perform one single task.
Let's say for example that we have a Search
input component. When a user clicks on the search input, we show a SearchPopup
component that shows some popular locations.
To create this behavior, we can create a FlyOut
compound component.
This FlyOut
component is an example of a compound component, as it also exposes some sub-components that all work together to toggle and render the FlyOut
component.
import React from "react";
import { FlyOut } from "./FlyOut";
export default function SearchInput() {
return (
<FlyOut>
<FlyOut.Input placeholder="Enter an address, city, or ZIP code" />
<FlyOut.List>
<FlyOut.ListItem value="San Francisco, CA">
San Francisco, CA
</FlyOut.ListItem>
<FlyOut.ListItem value="Seattle, WA">Seattle, WA</FlyOut.ListItem>
<FlyOut.ListItem value="Austin, TX">Austin, TX</FlyOut.ListItem>
<FlyOut.ListItem value="Miami, FL">Miami, FL</FlyOut.ListItem>
<FlyOut.ListItem value="Boulder, CO">Boulder, CO</FlyOut.ListItem>
</FlyOut.List>
</FlyOut>
);
}
The FlyOut
compound component is a stateful component - which means we don't have to add the stateful logic to the SearchInput
component.
Implementation
We can implement the Compound pattern using either a Provider, or React.Children.map
.
Provider
The FlyOut
compound component consists of:
FlyoutContext
to keep track of the visbility state ofFlyOut
Input
to toggle theFlyOut
'sList
component's visibilityList
to render theFlyOut
'sListItems
sListItem
that gets rendered within theList
.
const FlyOutContext = React.createContext();
export function FlyOut(props) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const toggle = React.useCallback(() => setOpen((state) => !state), []);
return (
<FlyOutContext.Provider value={{ open, toggle, value, setValue }}>
<div>{props.children}</div>
</FlyOutContext.Provider>
);
}
function Input(props) {
const { value, toggle } = React.useContext(FlyOutContext);
return <input onFocus={toggle} onBlur={toggle} value={value} {...props} />;
}
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function ListItem({ children, value }) {
const { setValue } = React.useContext(FlyOutContext);
return <li onMouseDown={() => setValue(value)}>{children}</li>;
}
FlyOut.Input = Input;
FlyOut.List = List;
FlyOut.ListItem = ListItem;
Although we didn't have to name our compound component's sub-components FlyOut.<ComponentName>
, it's an easy way to identify compound components, and only requires a single import.
React.Children.map
Another way to implement the Compound pattern, is to use React.Children.map
in combination with React.cloneElement
. Instead of having to use the Context API like in the previous example, we now have access to these two values through props
.
export function FlyOut(props) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const toggle = React.useCallback(() => setOpen((state) => !state), []);
return (
<div>
{React.Children.map(props.children, (child) =>
React.cloneElement(child, { open, toggle, value, setValue })
)}
</div>
);
}
function Input(props) {
const { value, toggle } = React.useContext(FlyOutContext);
return <input onFocus={toggle} onBlur={toggle} value={value} {...props} />;
}
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function ListItem({ children, value }) {
const { setValue } = React.useContext(FlyOutContext);
return <li onMouseDown={() => setValue(value)}>{children}</li>;
}
FlyOut.Input = Input;
FlyOut.List = List;
FlyOut.ListItem = ListItem;
All children components are cloned, and passed the value of open
, toggle
, value
and setValue
.
Tradeoffs
State management: Compound components manage their own internal state, which they share among the several child components. When implementing a compound component, we don't have to worry about managing the state ourselves.
Single import: When importing a compound component, we don't have to explicitly import the child components that are available on that component.
Nested components: When using React.Children.map
, only direct children of the parent component will have access to the open and toggle props, meaning we can't wrap any of these components in another component.
function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks, since the direct child of FlyOut is now a div */}
<div>
<FlyOut.Input />
<FlyOut.List>
<FlyOut.ListItem>San Francisco, CA</FlyOut.ListItem>
<FlyOut.ListItem>Seattle, WA</FlyOut.ListItem>
</FlyOut.List>
</div>
</FlyOut>
);
}
Naming collisions: Cloning an element with React.cloneElement
performs a shallow merge. If we pass a prop that already exists on the
component, in this example open
or toggle
, a naming collision occurs, and
the value of these props will be overwritten with the latest value that we
pass.
Challenge
Exercise
Create a FlyOut
component that gets rendered by the Input
component. This component should contain:
FlyOut.Input
to render the input fieldFlyOut.List
to render the list itemsFlyOut.ListItem
to render the list items