adro.codes

Architecture definition for a button component

Problem: When building a React Button component, we often overcomplicate the implementation to the point of creating a "catch-all" component.

Question: How can we refine this component to have the same reusability without the bloat?

The problem pattern

A common pattern for a Button is to do the following.

import React from "react"

interface ButtonProps {
    text: string
    onClick?: (event: MouseEvent<HTMLButtonElement>) => void
    icon?: React.ReactNode
    iconPosition?: 'left' | 'right'
    variant?: 'default' | 'primary' | 'secondary' | 'link' | 'ghost'
    disabled?: boolean
    // with an ever growing list of props to handle edge-cases
}

const Button = (props: ButtonProps) => {
    // we
    // then
    // do
    // a
    // lot
    // of
    // gymnastics
    // to
    // get
    // this
    // to
    // render
    // properly
}

With this approach it is not common to end up with a Button component that is 100+ lines of code. Additionally, you may have noticed a problem already. MouseEvent<HTMLButtonElement>, what if I wanted this button to be an anchor (<a>) instead? The HTMLButtonElement would not apply. Additionally, onClick might not apply at all, we might just want the styling and pass in a href.

We can do some interface tricks for this to work.

interface BaseButton {
    text: string
    type: 'link' | 'button'
    icon?: React.ReactNode
    iconPosition?: 'left' | 'right'
    variant?: 'default' | 'primary' | 'secondary' | 'link' | 'ghost'
    disabled?: boolean
}

interface LinkButton extends BaseButton {
    type: 'link'
    href: string
    target: string
    onClick?: (event: MouseEvent<HTMLAnchorElement>) => void
}

// shut up, I know ButtonButton is stupid
interface ButtonButton extends BaseButton {
    type: 'button'
    onClick?: (event: MouseEvent<HTMLButtonElement>) => void
    buttonType: 'button' | 'reset' | 'submit'
}

const Button = (props: LinkButton | ButtonButton) => {
    const { type } = props

    // base on `type` we can render a <a> or <button>
}

With this example the amount of gynmastics drastically increases. Now lets introduce Next.js into the mix. It has its own <Link> component, however it is used to wrap an <a> tag. If we want to use the passHref prop, we'll probably have to tweak the interface, since now, href can be optional and it might have to work with forwardRef, which needs to work with both <a> and <button>.

I think at this point we can agree this is probably not useful, and will cause a simple button, that is used throughout your project, to be insanely complex.

Breaking down the issue

So, lets ask ourselves, why are we making this so complex? We know that many other libraries with Button components, allows you to switch the underlying DOM node. Many times they use the as keyword because of styled components. So should we just use that library or styled components?

When I have seen this used, it has been in the context of keeping styling consistent and easily editable at a global level. Many times we have to alter the className of a button based on the props. So if one of the main reasons is styling, why don't we just abstract that out and have different components for a <a> and <button> styled buttons? Once we use a button in our application and decided on the element needed, what are the chances that it will need to change ever? Probably really low, like really low. So lets address the styling shall we.

Styling abstraction

Let's start with our CSS, just going to keep this simple. We have a root class with different variations based on the props passed down.

.button {}

/* Variant specific classes */
.button--default {}
.button--primary {}
.button--secondary {}
.button--link {}
.button--ghost {}

/* State specific classes */
.button--disabled {}
.button--external {}

/* Icon specific classes */
.button--has-icon {}
.button--has-icon--start {}
.button--has-icon--end {}

/* (Maybe) Element specific classes */
.button--anchor {}
.button--button {}

Cool, so that is a bit, but it isn't uncommon to see this amount of classes and style variations for a Block Component (or Atom, depending on your preference). Each one of these classes maps to different props and can help to define/refine your base props. So yes, that BaseButton interface is staying. WOOOO. But not in the same way.

type ButtonVariant = 'default' | 'primary' | 'secondary' | 'link' | 'ghost'
type ButtonIcon = React.ReactNode

interface BaseButton {
    startIcon?: ButtonIcon
    endIcon?: ButtonIcon
    variant?: ButtonVariant
    disabled?: boolean
}

Here is our base component interface, we have also created some additional types if we ever want to use these again, not 100% required but for now, it'll work.

So how are we taking these props and transforming them into all the className of the button? Easy, write a hook.

React Hooks don't have to wrap other hooks (useState for example), they can just be used to wrap functionality. We will also make use of a npm package called classnames, this allows us to easily create className strings. It isn't required, just makes life easier.

import classnames from 'classnames'

export const BASE = 'button'

export const useButtonStyles = ({
    startIcon,
    endIcon,
    variant = 'default',
    disabled = false
}: BaseButton): string => {

    return classnames([
        BASE,
        `${BASE}--${variant}`,
        {
            [`${BASE}--disabled`]: disabled,
            [`${BASE}--has-icon`]: startIcon || endIcon,
            [`${BASE}--has-icon--start`]: startIcon,
            [`${BASE}--has-icon--end`]: endIcon
        }
    ])
}

All this really does is take our base props and produce a list of classes based on the conditions we added.

Building the buttons

Now that we have this hook that will create our base class names, how do we apply it to our different button types. Pretty easily, instead of creating 1 component to rule them all, we create multiple components based on the use case.

Before we show that though, I want to briefly talk about extending HTML attributes as props. When using typescript, you can extend other interfaces which helps to create more generic interfaces and build up the complex interfaces. One really useful aspect here is the ability to extends HTML attributes based on the element.

interface Button extends HTMLAttributes<HTMLButtonElement> {}

This will expose all the HTML attributes available to a <button> element without needing to define them yourselves. Depending on your project, you might not want/need this flexibilty. However, for the examples below, I will be using them.

Button

import classnames from 'classnames'
import { useButtonStyles, BASE } from './useButtonStyles'

interface ButtonProps extends BaseButton,
    ButtonHTMLAttributes<HTMLButtonElement> {}

const Button = ({
    startIcon,
    endIcon,
    variant,
    disabled,
    children,
    className = '',
    ...buttonAttributes
}: ButtonProps) => {
    const baseClassname = useButtonStyles({
        startIcon,
        endIcon,
        variant,
        disabled
    })
    const classname = classnames([baseClassname, className, `${BASE}--button`])

    return (
        <button className={classname} {...buttonAttributes}>
            {startIcon}
            {children}
            {endIcon}
        </button>
    )
}

Anchor

import classnames from 'classnames'
import { useButtonStyles, BASE } from './useButtonStyles'

interface AnchorProps extends BaseButton,
    AnchorHTMLAttributes<HTMLAnchorElement> {}

const Anchor = ({
    startIcon,
    endIcon,
    variant = 'link',
    disabled,
    children,
    className = '',
    target,
    ...anchorAttributes
}: AnchorProps) => {
    const baseClassname = useButtonStyles({
        startIcon,
        endIcon,
        variant,
        disabled
    })
    const classname = classnames([
        baseClassname,
        className,
        `${BASE}--anchor`,
        {
            [`${BASE}--external`]: target && target === "_blank"
        }
    ])

    return (
        <a className={classname} target={target} {...anchorAttributes}>
            {startIcon}
            {children}
            {endIcon}
        </a>
    )
}

When using these components, they now feel a lot of purposeful, meaninging the component does what you expect. A Anchor is a <a> and a Button is a <button>. Additionally, it will make other components in your application a lot more descriptive without needing inspect the props to determine what the component will render.

Additionally, just in these two components there were a very small changes that would have been, not annoying, but would add additional conditional into your base component to achieve which HTML element is rendered, what additional classes should be added etc.

You'll also notice that our interfaces are really simple, the HTMLAttributes interface really does the heavy lifting for us in this case, again, making these components feel more native, which is exactly what you want for a Block/Atom Component.

Usage

This sandbox shows the above code implemented to a relatively final state, the styling is a bit basic just to show off the different variants so that will need clean up and finalised. However, the React components have been written in the same way as above and everything seems to be working quite well.

One of the changes I did was to wrap the Anchor in a forwardRef. This will allow us to use this custom component with the Next.js Link component.

import Link from "next/link";
import { Anchor } from "./Button";

<Link href="/" passHref>
    <Anchor>Next js Route</Anchor>
</Link>

The great thing about this change is, it doesn't alter the implementation of the Button at all, and existing Anchor elements in the app will work perfectly fine as they did before. We were also able to type the ref object to be specifically a HTMLAnchorElement instead of a HTMLAnchorElement|HTMLButtonElement. Which again, adds in those conditional checks and also might behave weirdly with the <Link> component.