What is composition in React?
Composition is a pattern where components are tied up together like building blocks to create complex UIs.
Instead of relying too much on props and foul yourself that if you add one more prop the component will then be more customizable, you should always try to use this pattern whenever possible to avoid and minimize the amount of props your component has.
Take a look at the following ConfiguredModal.tsx component, starting with the necessary types:
type ConfiguredModalProps = {
isOpen: boolean;
onClose: () => void;
title?: string;
withDelimiter?: boolean;
withFooter?: boolean;
children: React.ReactNode;
headerActions?: () => React.ReactNode; // edit button, download
// ... more props
};
For the sake of simplicity and to not scare you with too much CSS, I will hide the className attributes from all the elements.
export default function ConfiguredModal({
isOpen,
onClose,
headerActions,
title,
withDelimiter,
withFooter,
children,
}: ConfiguredModalProps) {
if (!isOpen) return null;
return (
<div aria-modal="true" role="dialog" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>
<CircleX />
</button>
<div>
{title && <h2>{title}</h2>}
{headerActions ? headerActions() : null}
</div>
<div>{children}</div>
{withDelimiter && (
<span>
<hr />
</span>
)}
{withFooter && (
<footer>
<PrimaryButton onClick={() => console.log("Confirm clicked")}>
Confirm
</PrimaryButton>
</footer>
)}
</div>
</div>
);
}
As you probably noticed, this component has a lot of props, even if it doesn't look like it, because you would say: "Huh, 7 props are not many, right?". Well… that depends. By just looking at the code, this component is very hard to read. And also, even on the consumer part, it looks tedious with all these props:
<ConfiguredModal
title="My Modal Title"
isOpen={isOpen}
onClose={() => setIsOpen(false)}
withDelimiter
headerActions={() => (
<div>
<PrimaryButton
variant="outlined"
onClick={() => console.log("Edit clicked")}
>
Edit
</PrimaryButton>
<PrimaryButton
variant="outlined"
onClick={() => console.log("Download clicked")}
>
Download
</PrimaryButton>
</div>
)}
withFooter
>
<p>Some text..</p>
</ConfiguredModal>;
Even if this is technically correct, we have too many conditionals: If title exists, then display title. If there is a HeaderActions component that should be displayed just display it or return null, show a delimiter if withDelimiter is provided or it's true, same for the footer.
Wouldn't it be nice if we would have a nice way to keep the same functionalities, but with a more readable and composable component?
Well, fortunately, there is a way.
The Donut pattern in practice
The "Donut pattern" or "The composition pattern" is one of the most fundamental yet important patterns in building complex UI. It removes the configuration needs(adding more props) and it promotes composition as its root technique. To see this pattern in action, I created the optimized composed version of the ConfiguredModal.tsx, which resides in the ComposedModal.tsx component:
export default function ComposedModal({
isOpen,
onClose,
children,
}: ComposedModalProps) {
return (
isOpen && (
<div aria-modal="true" role="dialog" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>
<CircleX />
</button>
{children}
</div>
</div>
)
);
}
This looks much clearer, right? We only keep the "skeleton" of our modal inside the component, but the interesting part comes next. Now that we defined the basic functionality of the modal, it's time to define it's parts and components like so:
type HeaderProps = {
title: string,
children: React.ReactNode,
};
function Header({ title, children }: HeaderProps) {
return (
<div>
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
function Body({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
function Footer({ children }: { children: React.ReactNode }) {
return (
<>
<span>
<hr />
</span>
<footer>{children}</footer>
</>
);
}
ComposedModal.Header = Header;
ComposedModal.Body = Body;
ComposedModal.Footer = Footer;
We tell React that our ComposedModal can have 3 parts: A Header, a Body, and a Footer. For this, we write a simple component for each part of the modal, where we basically only define the required structure of the respective component. We basically tell the consumer: "We give you the structure that we want, but you can put anything in there and style it however you want".
This way, we ensure that the "final" component will be composable and we give the user the power to choose if they want to display a certain part of the modal or not by simply not putting anything in there.
Now, on the consumer part, the code is a lot clearer and it looks like a donut, because the consumer places everything that he needs as children, but in a structured and composed way:
export default function ExamplePage() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<ComposedModal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<ComposedModal.Header title="My modal title">
<div>
<PrimaryButton
variant="outlined"
onClick={() => console.log("Edit clicked")}
>
Edit
</PrimaryButton>
<PrimaryButton
variant="outlined"
onClick={() => console.log("Download clicked")}
>
Download
</PrimaryButton>
</div>
</ComposedModal.Header>
<ComposedModal.Body>
<p>Some text...</p>
</ComposedModal.Body>
<ComposedModal.Footer>
<PrimaryButton onClick={() => console.log("Confirm clicked")}>
Confirm
</PrimaryButton>
</ComposedModal.Footer>
</ComposedModal>
</>
);
}
Top comments (1)
Waw, nunca soube desta tecnica, valeu muito.