With my current development team, I'm building a reusable react component library. Some of these components, like our <Button/>
, are fairly simple. I can easily expose all the Button's options and functionalities through its props. Something like this:
<Button
id="my-cool-button"
onClick={() => console.log("I got clicked!")}
variant="secondary"
disabled={false}
size="large">
Press Me!
</Button>
But even for such a simple example, we end up with quite a few lines of code. This problem of having too many props gets far more dramatic when we begin looking at complex "composed-components" which are made up of many different pieces.
Take, for example, the <Modal/>
. Our design includes: a header with multiple configurations including primary title, subtitle, a hero image, and/or a close button; a call-to-action footer allowing a variable number of buttons in different layouts; and a primary content section. In addition to the standard props of each Text, Image, and Button component for all of these items, our consumers have asked for the ability to do additional layout and style customization.
That's a lot of props!
There has to be a better way to do this -- and there is! This blog post aims to cover the solution that our team has taken in answering the following question:
How can we build "composed-components" to provide default styles, customizability, and a clean API?
And how to do you do it all in TypeScript? 😉
The Problem
First, let's dig a little deeper into exactly what we want to accomplish.
To start consuming our library, all a team has to do is yarn add "our-react-library"
and import { Component } from "our-react-library";
. Most developers won't ever look at our code; instead, they'll browse component documentation in our interactive Storybook. As we prioritize ease-of-use, we want all of our components to look great out of the box.
In addition to a React library, our team also publishes global design standards and a design component library for use across the company. However, there are often edge cases or situations where a team wants to make tweaks or changes. Those are easy enough to approve in design, but often require us to expose many layers of classNames (eww) or add even more props in React. Our solution for the modal needs to support multiple variants, but avoid introducing an ungainly number of props.
Finally, we always want to provide a great developer experience -- that means delivering an API that allows our users to write clean, concise code. Using long component names like ModalPrimaryTitle
or polluting our package namespace with generic PrimaryTitle
aren't acceptable solutions. Neither is using nested objects as props to hide config or options, which is difficult to document and doesn't work well with Storybook. And of course, we want to build TypeScript-first 🦸♂️.
The Process
I started this journey with our old modal, which had a lot of props and still was very challenging to customize. And, the new design our team came up with included more options and flexibility than before.
We knew very early on that we wanted to avoid any solution that was too prop-heavy, which pushed us towards exposing multiple elements to the user. Something like this:
<Modal>
<h1>Main Title</h1>
<h2>Subtitle</h2>
{bodyContent}
<button>CTA 1</button>
</Modal>
One early suggestion was to update our code-snippet generator in Storybook to return raw HTML that looked like a modal, so we didn't even need to make a component. But, that solution would detach our consumers code from our library, rendering us unable to push new features or fixes without them updating their code. It would also be difficult to style, because we used styled-components instead of relying on class names or bundling a stylesheet.
Still, we liked the direction we were headed in. The next suggestion was to provide a simple Modal that acted as a container, allowing users to pass our other existing components into it. But the layout was too complex to tackle without additional wrappers for the header and footer, so we added those which gave us greater customizability.
import {
Modal,
ModalHeader,
ModalContent,
ModalFooter,
Text,
Button
} from "our-react-library";
<Modal>
<ModalHeader>
<Text as={"h1"} size={6} weight={"bold"}>Main Title</Text>
<Text as={h2} size={4}>Subtitle</Subtitle>
<ModalHeader>
<ModalContent>
{bodyContent}
</ModalContent>
<ModalFooter>
<Button
variant={"primary"}
size={"large"}
onClick={() => console.log("Clicked!")}
/>
Go forth and prosper
</Button>
</ModalFooter>
</Modal>
This is looking better, but it still had a few issues. Namely, we were (1) asking our users to manually apply default styles to the title, subtitle, call to action buttons, and more; and (2) we were polluting our namespace with lots of Modal-specific components. The solution to the 1st problem is easy, but it exasperates the second problem: introduce a ModalTitle
component, a ModalSubtitle
component, a ModalCTA
component, etc. Now if we can just find a simple place to put all those pesky components, we would have a pretty good solution!
What if we put the sub-components on Modal
itself?
The Solution
Below is the API we decided on. Every component matches our design out of the box, but also allows for customization using CSS classes or styled-components. Adding or removing full sections or adding custom components anywhere in the flow is fully supported. The API is clean and concise and most importantly the namespace is immaculate.
import { Modal } from "our-react-library";
<Modal>
<Modal.Header>
<Modal.Title>Main Title</Modal.Title>
<Modal.Subtitle>Subtitle</Modal.Subtitle>
</Modal.Header>
<Modal.Content>
This is my body content
</Modal.Content>
<Modal.Footer>
<Modal.CTA>Click me!</Modal.CTA>
</Modal.Footer>
</Modal>
Now I know what you're thinking, "That looks great, but how can you make that work in TypeScript?"
I'm glad you asked.
We use React functional components to build most of our library, so inside the library, our files look something like this:
export const Button = ({ children, size, ...props}: ButtonProps): JSX.Element => {
if (size === "jumbo") {...}
return (
<StyledButton size={size} {...props}>
{children}
</StyledButton>
);
}
TypeScript, however, does not allow us to assign additional props to a const, especially after we export it. This poses a problem. Somehow we have to attach props to what is essentially a function without writing a ton of duplicate code. Another pesky problem is setting correct displayNames for React DevTools and, more importantly, our Storybook code generator.
Here's the magic function:
import React from 'react';
/**
* Attaches subcomponents to a parent component for use in
* composed components. Example:
*
* <Parent>
* <Parent.Title>abc</Parent.Title>
* <Parent.Body prop1="foobar"/>
* </Parent>
*
*
* This function also sets displayname on the parent component
* and all children component, and has the correct return type
* for typescript.
*
* @param displayName topLevelComponent's displayName
* @param topLevelComponent the parent element of the composed component
* @param otherComponents an object of child components (keys are the names of the child components)
* @returns the top level component with otherComponents as static properties
*/
export function attachSubComponents<
C extends React.ComponentType,
O extends Record<string, React.ComponentType>
>(displayName: string, topLevelComponent: C, otherComponents: O): C & O {
topLevelComponent.displayName = displayName;
Object.values(otherComponents).forEach(
(component) =>
(component.displayName = `${displayName}.${component.displayName}`)
);
return Object.assign(topLevelComponent, otherComponents);
}
The above code lives in a util file and can easily be imported within our library any time we want to use sub-components. That allows us to write a modal component file that is very easy on the eyes:
export const Modal = attachSubComponents(
"Modal",
(props: ModalProps) => { ... },
{ Header, Content, Footer, Title, Subtitle, HeroImage, ... }
);
And best of all, it's a great solution for all of our users!
Thanks for reading! I hope this technique for creating clean composed components in React will level-up you and your team.
Kaeden Wile
wile.xyz
Top comments (0)