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:
FlyoutContextto keep track of the visbility state ofFlyOutInputto toggle theFlyOut'sListcomponent's visibilityListto render theFlyOut'sListItemssListItemthat 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.Inputto render the input fieldFlyOut.Listto render the list itemsFlyOut.ListItemto render the list items