Torstein Thune

Composable React Components

This blog post will explore composition in React components and propose a few patterns to achieve it.

Introduction: what is composition?

Functional composition is the act of taking the output of one function and passing it directly as the input to another function.

For example, consider the following three functions:

const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const echo = str => `${str} ${str}`;

They have the same function signature: string => string. This means that they are composable:

const shout = str => exclaim(scream(echo(str)));

We can write a quick utility function:

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

And now we can compose our three functions into one:

const shout = compose(exclaim, scream, echo);

Composition in React

React components are (for the most part) functions. Likewise, JSX is simply a way of writing function-calls with XML-syntax.

// This component definition:
const Element = () => <h1 className="greeting">Hello, world!</h1>;

// Compiles to:
const Element = () => React.createElement("h1", { className: "greeting" }, "Hello, world!");

There are three parts to a JSX function call: the component to use (i.e function to call), the props, and children.

We can make all three dynamic!

Dynamic function call

Firstly we will look at the function call. Making this dynamic lets us decouple our compoment structure from the underlying HTML or SVG structure, which in turn gives us a lot of flexibility.

const Component = ({ as="div", ...rest}) => {
    const ComponentToRender = as;
    return <ComponentToRender {...rest} />;
};

This lets us do a couple of things: change behaviour by for instance doing things like rendering a button as an anchor tag and think about semantic html by for instance rendering a Card component as a section.

Rule 1:
Render as you wish

Dynamic props: the magic ...rest "splat"

Making the props dynamic is easier, we simply splat and pass on.

const Component = ({ as="div", ...rest}) => {
    const ComponentToRender = as;
    return <ComponentToRender {...rest} />;
};

This might seem like a strange thing to do. Why would we pass on arbitrary props?

The reason is simple, in the code example above we render a div, do you know all the attributes a div can take? I don't.

By splatting and passing on the props we can pass on any attribute that the underlying element. This lets us do a11y-tweaks like setting aria-label, it lets us style things and more.

It also combines nicely with the dynamic function call we added:

<Component as="button" onClick={console.log}} />
Rule 2:
Splat and pass

Children

In Vue.js and Angular we have slots, in React we have children.

Children are simply nested function calls, strings or null. The rule here is to "always place the slot", meaning that you should always strive to pass children on to the underlying component layer.

Consider the following component:

const Button = ({ buttonLabel, ...rest }) => <button {...rest}>{buttonLabel}</button>;

This component is largely reusable. It lets you do typical button-stuff like event-handlers, styling and even accessibility-tweaks.

However, it has one major drawback: if we want to add an icon to the button, we need to write a new component.

Luckily, we already have a reusable, dynamic, composable Component available:

// Note: by passing rest directly we already placed the slot
<Component as="button" onClick={console.log}>
    <Component as="div" className="flex gap-2">
        <Component as="img" src="icon.png" />
        <Component as="span">Label</Component>
    </Component>
</Component>

It is absurd to use one component like this, but I hope it illustrates the point that we can use the Component as a base for all our components.

Rule 3:
Always place the slot

Composable styling with Tailwind

The concept of composition can extend to styling as well.

Essentially we want to concatinate class names in each nested function call, and then dedupe them at the end.

The simplest way of doing this is with Tailwind CSS and tailwind-merge

import { twMerge } from "tailwind-merge";

/**
 * We define a BaseComponent that all our components will use (eventually)
 * This component takes an "as" prop to make the underlying element dynamic,
 * a className prop for styling and splats the rest of the props.
 * It also merges the className using tailwind-merge to allow for
 * composable styling.
 */
const BaseComponent = ({ as="div", className="", ...rest}) => {
    const Element = as;
    const mergedClassName = twMerge(className);
    return <Element className={mergedClassName} {...rest} />;
};

/**
 * We define a Button component that uses the BaseComponent.
 * It adds some default styling, but concatenates any className passed in.
 */
const Button = ({ className="", as="button", ...rest}) => {
    return <BaseComponent as={as} className={"bg-blue-500 text-white p-4 " + className)} {...rest} />;
};

/**
 * We can now use the Button component and override the styling.
 * twMerge will ensure that the final className string is valid Tailwind CSS.
 */
const App = () => {
    return <Button className="bg-red-500">Click me</Button>; // The button will have a red background
};

This lets us create reusable components with composable styling, with the passed className (as long as it is added to the end) being more significant the further out in the React component hierarchy that it is passed.

Rule 4:
Compose the class

Summary: The four rules of composable React components

Rule 1: Render as you wish

Rule 2: Splat and pass

Rule 3: Always place the slot

Rule 4: Compose the class

Atomic design: the reusability pyramid

Atomic design is a methodology for creating component systems. It breaks components down into five groups: atoms, molecules, organisms, templates and pages. For the purpose of this article we will ignore pages.

This is highly applicable to how we want to structure our components.

Atoms

Everything we showed in the previous section is an atom, with the BaseComponent being the basic building block of your React component system.

Atoms are very easy to re-use, they are easy to compose, and they are easy to make.

They are however dumb. Logic should be kept to a minimum, and event handling should happen in other layers of the pyramid.

Molecules

Molecules are atom(s) put into system. Molecules have stricter API contracts than atoms, and are not as re-usable as their atom counterparts.

There are two strategies you can employ when building molecules, expose the interfaces of the inner atoms or create a specialised interface for the molecule.

Exposing the interfaces of the inner atoms

import { BaseComponent, BaseComponentProps } from "./BaseComponent";


/**
 * We define a Popup component that is composed of
 * four atoms: PopupShell, PopupHeader, PopupBody and PopupFooter.
 * Each atom uses the BaseComponent to allow for
 * dynamic element type, styling and props.
 * 
 * The Popup component - a molecule - takes props for each atom,
 * and splats any additional props to the PopupShell.
 * It also places children in the PopupBody.
 */
interface PopupShellProps extends BaseComponentProps {}
export const PopupShell = ({as="dialog", ...rest}) => {};

interface PopupHeaderProps extends BaseComponentProps {}
export const PopupHeader = ({as="h1", ...rest}) => {};

interface PopupBodyProps extends BaseComponentProps {}
export const PopupBody = ({as="section", ...rest}) => {};

interface PopupFooterProps extends BaseComponentProps {}
export const PopupFooter = ({as="footer", ...rest}) => };

interface PopupProps extends PopupShellProps {
    header: PopupHeaderProps;
    body: PopupBodyProps;
    footer: PopupFooterProps;
}

export const Popup = ({ header, body, footer, children, ...rest }: PopupProps) => {
    return (
        <PopupShell {...rest}>
            <PopupHeader {...header} />
            <PopupBody {...body}>{children}</PopupBody>
            <PopupFooter {...footer} />
        </PopupShell>
    );
}

The Popup is created using multiple building blocks. It also uses interfaces in order to allow you to customize each atom within the Popup molecule.

Additionally, it serves as a living example of how to build a new Popup component if it fails to serve your concrete need.

Creating a specialised interface for the molecule


// Imagine you have two atoms: Button and Icon
import { Button, ButtonProps } from "./Button";
import { Icon, IconProps } from "./Icon";

/** 
 * IconButton is a molecule that insists on only displaying an icon
 * It also forces the user to provide a title for accessibility.
 */
export interface IconButtonProps extends Omit<ButtonProps, "children"> {
    icon: string;
    title: string;
};

export const IconButton = ({ icon, ...rest }: IconButtonProps) => {
    return <Button {...rest}><Icon src={icon} /></Button>;
};

In this case we limit the user to passing an icon and at the same time force a title. We intentionally limit the composability and dynamicity of the Button atom, while at the same time providing an example of how to use the Button atom in a different scenario.

Organisms

Organisms are molecules (and atoms) put into system, they take high level state and are responsible for event handling.

They are the place you should want to do most of your logic.

An organism could be a login form.

Templates

Templates are organisms (and molecules and atoms) put into system.

They are responsible for data fetching, context providers, and other things required to turn your stack of components into an application.

This is where you would set up things such as ReactRouter, a Redux store, ReactQuery or similar.

This is also where you would do things such as checking if a user is logged in displaying a LoginForm organism if not or displaying a Dashboard organism if they are.

Conclusion

In this article we have looked at some tricks that makes composing React components joyful.

We have talked about how to make a base component that takes in as="" (which component to render as), className="" (a composable class list that is merged), children and ...rest and passes them on to react-dom.

We have also talked about the Atomic Design pattern and how to use it to make your components more reusable.

Further reading

I'm working on a component system based on this trail of thought called JacketUI Components, check out the BaseComponent and components built on top of it.

© Torstein Thune