Best way to create Big React components

Together with my current development team, I am building a reusable react component library. Some of these components, such as our <Button/>, are quite simple. I can easily highlight all the options and functionalities of the button 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>
enter fullscreen mode

exit fullscreen mode

But even for such a simple example, we end up with a few lines of code. This problem of having too many props becomes far more dramatic when we start looking at complex “composition-components” made up of many different pieces.

For example, take <Modal/>, Our designs include: a header with multiple configurations, including a primary title, a subtitle, a hero image, and/or a close button; A call-to-action footer allows a variable number of buttons in different layouts; and a primary content section. In addition to the standard props for each text, image, and button component for all of these items, our consumers have asked for the ability to make additional layout and style customizations.

That’s a lot of props!

There must be a better way to do this – and there is! The purpose of this blog post is to cover the solution that our team has come up with in answering the following question:

How can we build “compose-components” to provide default styles, customizability, and a clean API?

And how do you do all this in TypeScript? I


Problem

First, let’s take a deeper look at what we want to achieve.

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 will never even look at our code; Instead, they will browse the 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 the React library, our team also publishes a design component library for global design standards and use across the company. However, there are often cases or situations where a team wants to change or make changes. They are easy enough to take for granted in design, but often we need to expose multiple layers of classnames (ew) or add even more props to React. For modals our solution needs to support multiple forms, but avoid introducing an infinite number of props.

In the end, we’ve always wanted to provide a great developer experience – this means providing an API that allows our users to write clean, concise code. Using long component names like ModalPrimaryTitle or polluting our package namespace with generics PrimaryTitle There are no acceptable solutions. Neither is using nested objects as props to hide configuration or options, which is difficult to document and doesn’t work well with Storybooks. And yes, we want to make a TypeScript-first ️.


process

I started this journey with myself old model, which had a lot of props and was still very challenging to customize. And, the new design our team introduced includes more options and flexibility than ever before.

We learned long ago that we wanted to avoid any solution that was too prop-heavy, pushing us to expose too many elements to the user. Something like this:

<Modal>
    <h1>Main Title</h1>
    <h2>Subtitle</h2>
    {bodyContent}
    <button>CTA 1</button>
</Modal>
enter fullscreen mode

exit fullscreen mode

An initial 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 create a component. But, that solution would isolate our consumer code from our library, leaving us unable to push new features or improvements without updating their code. It would also be difficult to style, because we used style-components instead of relying on class names or bundling stylesheets.

Still, we liked the direction we were going. The next suggestion was to provide a simple model that acted as a container, allowing users to pass our other existing components into it. But the layout was too complex to deal with without additional wrappers for the header and footer, so we added those that gave us more 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>
enter fullscreen mode

exit fullscreen mode

It looks better, but it still had some problems. Namely, we were (1) asking our users to manually apply default styles for titles, subtitles, call to action buttons, and more; and (2) we were polluting our namespace with too many model-specific components. The first problem is easy to solve, but it exacerbates the second problem: Introduction a ModalTitle component, a ModalSubtitle component, a ModalCTA components, etc. Now if we can find a simple place to put all those weird components, we’ll have a really cool solution!

What if we keep subcomponents running Modal on one’s own?


Solution

Below is the API we decided on. Each component matches our design, but also allows customization using CSS classes or style-components. Adding or removing complete sections or adding custom components anywhere in the flow is fully supported. The API is clean and concise and The most important thing The namespace is spotless.

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>
enter fullscreen mode

exit fullscreen mode

Now I know what you’re thinking, “This looks great, but how do you make that work in TypeScript?”

I’m glad you asked.

We use React Functional Components to build most of our libraries, 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>
    );
}
enter fullscreen mode

exit fullscreen mode

However, TypeScript gives us a . does not allow assigning additional props to constant, especially when we export it. This causes a problem. Somehow we have to essentially tie props to a function without writing a ton of duplicate code. Another strange problem is setting the correct displayname for React DevTools and more importantly our storybook code generator.

Here is 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);
}
enter fullscreen mode

exit fullscreen mode

The above code resides in a util file and can be easily imported into our library whenever we want to use the sub-components. This allows us to write a model component file which is very easy on the eyes:

export const Modal = attachSubComponents(
    "Modal",
    (props: ModalProps) => { ... },
    { Header, Content, Footer, Title, Subtitle, HeroImage, ... }
);
enter fullscreen mode

exit fullscreen mode

And the best part is that it is a great solution for all our users!


Thanks for reading! I hope this technique for creating neatly composed components in React will level you and your team.

cayden ville
trick.xyz

Leave a Comment