Extending Boot Strap In Type Script

Intro

Hi! Over the past year or 2 whilst building multiverseonline.io (the web site for our app, Multiverse) I’ve had to add React to the list-of-stuff-I-kind-of-know. The site is driven by React, using React-Bootstrap on top, and all written in TypeScript. Unfortunately however, in my quest for knowledge on the subject, I’ve often found stack overflow and other sources can be summed up with the phrase:,

“there’s a million opinions on your question, but unless you know the right answer to your question, you can’t tell which is the right opinion

My issue in this post is a classic example of said questions. There’s plenty of answers, but identifying the correct one is none-trivial!

My Bootstrap Problem

React-Bootstrap has all sorts of handy stuff for creating nice React components, such as the Button

<Button variant='primary'>
    Some text
</Button>

Giving us results like this (from the Bootstrap documentation):

However, recently I wanted to create a nice reusable version of the light button that had an info icon inside it:

This sort of thing is exactly what React is designed to do, so with a pretty icon (from react-icons) and a tiny bit of code:

<Button variant="light" className="mv-circular-button">
    <BsInfo/>
</Button>

Along with some simple CSS:

.mv-circular-button {
    position: relative;
    height: 40px;
    width: 40px;
    padding: 0;
    border-radius: 50%;
    border: none;
    box-shadow: none !important;
}

.mv-circular-button svg {
    width: 75%;
    height: 75%
}

We have a simple button consisting of the following:

  • The Bootstrap Button with the light variant creates a nicely styled button element.
  • The BsInfo component comes from react-icons and is an SVG with that pretty ‘i’ inside it.
  • The button CSS makes it a fixed width/height, sets the border to 50% so it becomes circular, and removes the annoying shadow effect Bootstrap adds
  • The SVG CSS just enforces a size for the icon

All good so far, but react is all about encapsulation and reuse of components, and I want to use that same info button in multiple places.

A simple functional component

Well the obvious (and I’m told the 2021 way of doing things) is to create a simple functional component like this one:

export const MyInfoButton = () => (
    <Button variant="light" className="mv-circular-button">
        <BsInfo/>
    </Button>
)

It’s in a separate TSX file, and is called MyInfoButton. Now within my page I simply have to write:

<MyInfoButton/>

And we have a button! Of course a button isn’t much use unless it does something when you click it, so let’s add an onClick event…

<MyInfoButton
    onClick={() => console.log("hello")}
/>

As anybody with a bit of TypeScript/React experience will point out, this immediately fails to compile. We haven’t defined any properties at all for the info button yet, so the onClick property is unrecognised. To make our original component work, we need to define and pass in some properties:

type MyInfoButtonProperties = {
    onClick?: ()=>void
}
export const MyInfoButton = (props: MyInfoButtonProperties) => (
    <Button onClick={props.onClick} variant="light" className="mv-circular-button" >
        <BsInfo/>
    </Button>
)

The MyInfoButton component now takes a set of properties defined by MyInfoButtonProperties. The only property for now is onClick, which is passed directly through to the Button component.

More properties

Now what if I want to start expanding on this? Maybe I want to be able to add a mouse move event and specify whether the button is disabled. Each of these are properties Button already supports, so I could just add them to MyInfoButtonProperties and pass them through as before:

type MyInfoButtonProperties = {
    onClick?: ()=>void;
    disabled?: boolean;
    onMouseMove?: ()=>void;
}
export const MyInfoButton = (props: MyInfoButtonProperties) => (
    <Button 
        onClick={props.onClick} 
        onMouseMove={props.onMouseMove} 
        disabled={props.disabled} 
        variant="light" 
        className="mv-circular-button" >
        <BsInfo/>
    </Button>
)

But we’re just duplicating properties of the internal button here, which feels a little messy. To clean things up a little, we can use the ‘…’ syntax to help out:

type MyInfoButtonProperties = {
    onClick?: ()=>void;
    disabled?: boolean;
    onMouseMove?: ()=>void;
}
export const MyInfoButton = (props: MyInfoButtonProperties) => (
    <Button {...props} 
        variant="light" 
        className="mv-circular-button" >
        <BsInfo/>
    </Button>
)

This simply passes anything in props through to the internal Button component. Much cleaner!

What about ALL the properties?

The model we’ve taken thus far is to work out what functionality from the internal Button component we want to expose, and provide properties to do exactly that – no more, and no less. This very constrained model makes perfect sense a lot of the time. Tightly constrained interfaces like this are a great way to keep your code understandable and reduce bugs. However, despite many an angry stack overflow comment, there are exceptions, and this is not ‘always the right thing to do’ (I hate that phrase)!

In this case I’m really just trying to add a small bit of functionality to the existing very powerful button. I specifically want a user of MyInfoButton to have immediate access to the 100s of properties any normal button has.

For a normal html button element (as apposed to a Bootstrap one), we can use the types supplied by React:

type MySimpleInfoButtonProperties = {
    //any custom bits
} & ButtonHTMLAttributes<HTMLButtonElement>
export const MySimpleInfoButton:FunctionComponent<MySimpleInfoButtonProperties> = (props) => (
    <button {...props} 
        className="mv-circular-button" >
        <BsInfo/>
    </button>
)

Here ButtonHTMLAttributes contains the list of all properties a standard HTML button can have. The generic HTMLButtonElement type argument is used in the definition of various event properties such as onClick.

Unfortunately for us, Bootstrap is not this simple!

Bootstrap defines its button like this:

import * as React from 'react';

import { BsPrefixComponent } from './helpers';

export interface ButtonProps {
  active?: boolean;
  block?: boolean;
  variant?:
    | 'primary'
    | 'secondary'
    | 'success'
    | 'danger'
    | 'warning'
    | 'info'
    | 'dark'
    | 'light'
    | 'link'
    | 'outline-primary'
    | 'outline-secondary'
    | 'outline-success'
    | 'outline-danger'
    | 'outline-warning'
    | 'outline-info'
    | 'outline-dark'
    | 'outline-light';
  size?: 'sm' | 'lg';
  type?: 'button' | 'reset' | 'submit';
  href?: string;
  disabled?: boolean;
}

declare class Button<
  As extends React.ElementType = 'button'
> extends BsPrefixComponent<As, ButtonProps> {}

export default Button;

Based on the above, the obvious starting point is a new set of properties as follows:

type MyInfoButtonProperties = {

    //any custom bits
} & ButtonProps & ButtonHTMLAttributes<HTMLButtonElement>

export const MyInfoButton = (props: MyInfoButtonProperties) => (
    <Button {...props} 
        variant={props.variant || "light"}
        className={"mv-circular-button " + (props.className || "")}>
        <BsInfo/>
    </Button>
)

Here I’ve extended the property type so it can include:

  • Any extra properties I might want to add later as my info button gets fancier
  • The core HTML attributes that any normal button needs, as per the previous pure react example
  • The bootstrap ButtonProps which include things like variant, size, disabled etc

I’ve also tweaked the 2 additional properties I pass to the internal button so that:

  • The default variant is light
  • The class name starts with “mv-circular-button” but can include additional classes passed in

So far we’re all good. My button still works!

The ‘as’ property

All good so far, but we’ve lost something important. Bootstrap supports a property named “as” on all its types, that allows you to override the HTML element used for the button. This functionality varies from pointless to useful to essential with Bootstrap, and when trying to create a generic, reusable info button, it’s not something I want to design out.

The problem is that our properties currently use ButtonHTMLAttributes<HTMLButtonElement>. This isn’t going to work for anything other than a button element!

Starting from the Bootstrap button, we can see it defines a generic type variable called“As”:

declare class Button<
  As extends React.ElementType = 'button'
> extends BsPrefixComponent<As, ButtonProps> {}

What we need to do is build the exact set of properties a Bootstrap Button needs, taking into account the “As” variable, so let’s follow things through. Button simply extends something called BsPrefixComponent<As,ButtonProps>. In the Bootstrap helpers file we find:

export class BsPrefixComponent<
  As extends React.ElementType,
  P = {}
> extends React.Component<ReplaceProps<As, BsPrefixProps<As> & P>> {}

BsPrefixComponent is a React class component (not a functional component like our info button), and it takes the rather confusing set of properties:

ReplaceProps<As, BsPrefixProps<As> & P>

In the context of our button, which defaults “As” to be ‘button’, and passes through ButtonProps as P, this resolves to:

ReplaceProps<'button', BsPrefixProps<'button'> & ButtonProps>

BsPrefixProps isn’t that interesting:

export interface BsPrefixProps<As extends React.ElementType> {
  as?: As;
  bsPrefix?: string;
}

It defines the “as” property and something called bsPrefix, so we’re creating a component that uses these 2 properties plus any in ButtonProps. But where do the 100s of other useful HTML ones come from? Well let’s look at the helpfully named ReplaceProps:

export type Omit<T, U> = Pick<T, Exclude<keyof T, keyof U>>;

export type ReplaceProps<Inner extends React.ElementType, P> = Omit<
  React.ComponentPropsWithRef<Inner>,
  P
> &
  P;

Note: ES5 for TypeScript defines its own version of Omit, which is different to the bootstrap version!

The “Inner” type variable is passed in from the “As” type variable which in our case defaults to ‘button’. Omit removes all the fields in one type that exist in another. In this case we’re:

  • Taking all the fields of the defined by React.ComponentPropsWithRef<'button'>
  • Removing any that are defined in P, which is currently BsPrefixProps<As,ButtonProps>
  • Then adding back all those that were removed!

So why do this? Well the important detail is that the removal doesn’t care about the types of fields – it simply deals with their names. Here Bootstrap is stripping out of the React type any fields defined in P, and replacing them with the versions from P.

For a simpler example of this type stripping, take the following code:

type TestType1 = {
    name: string;
    age: string;
}

type TestType2 = {
    age: number;
    address: string;
}

type CombinedType = TestType1 & TestType2;

let x: CombinedType = {
    name: "hello",
    age: 10,
    address: "somewhere"
};

Here we create 2 types, both of which define an ‘age’ field, but the first defines it as a string, and the second defines it as number. Because they don’t agree, CombinedType will end up with:

type CombinedType = {
    name: string;
    age: never;
    address: string;
}

The ‘age’ field has been assigned the type ‘never’, as it can never be both a number and a string. The result is that the final line in which we set age to 10 will result in the following TypeScript error:

Type ‘number’ is not assignable to type ‘never’.

The bootstrap technique uses Omit as follows:

type TestType1 = {
    name: string;
    age: string;
}

type TestType2 = {
    age: number;
    address: string;
}

/*
type TestType1WithoutAgeField = {
    name: string
}
*/
type TestType1WithoutAgeField = Omit<TestType1,TestType2>

/*
type CombinedType = {
    name: string;
    age: number;
    address: string;
}
*/
type CombinedType = TestType1WithoutAgeField & TestType2;

Long story short, Bootstrap’s type magic is giving us a new type with:

  • All those from P
  • 2 fields called ‘as’ and ‘bsPrefix’ from BsPrefixProps
  • Any remaining fields that the normal HTML element would have

In the case of Button this is:

  • The fields from ButtonProps
  • ‘as’ and ‘bsPrefix’ from BsPrefixProps
  • By default, any additional fields a button HTML element has

You might wonder how the text ‘button’ gets converted to “all the fields a button element would have”. If you follow through the react code for React.ComponentPropsWithRef what you’ll find is that ‘button’ is used as a key of the JSX.IntrinsicElements interface, defined as follows:

        interface IntrinsicElements {
            // ... lots of fields ...
            button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
            // ... lots more fields ...

        }
    }

With all this work, we can say that Button is a class component that takes the properties:

ReplaceProps<As, BsPrefixProps<As> & ButtonProps>

Where ‘As’ defaults to ‘button’.

From there, it’s a short step to conclude that the properties for the extended info button should be as follows:

type NewButtonProps = {
    //any new properties we want to add go here
} & ButtonProps

type MyInfoButtonProperties<As extends React.ElementType = 'button'> =
    ReplaceProps<As, BsPrefixProps<As> & NewButtonProps>

Creating the class component

So we’ve setup a new simple type that contains any new bits we want to add, plus the basic properties defined for the Bootstrap button. Then used the ReplaceProps type magic to add in any extras from the core HTML element. As with the rest of Bootstrap, the new MyInfoButtonProperties takes an ‘As’ type argument that can be used to override the element it represents in the DOM.

Before diving back into function world, let’s copy the Bootstrap pattern of a fully fledged class component

export class MyInfoButton<As extends React.ElementType = 'button'> 
    extends React.Component<MyInfoButtonProperties<As>> 
{
    render = () => {
        return (
            <Button {...this.props} 
                variant={this.props.variant || "light"}
                className={"mv-circular-button " + (this.props.className || "")}>
                <BsInfo/>
            </Button>
        )
    }
}

Armed with this new component, we can now go back to the original page and do something along the lines of…

<MyInfoButton
    as='a'
    target="bla"
    onClick={() => console.log("hello")}
/>

Note how the ‘as’ parameter is now available to us. Looking at it in the browser, you can see React is inserting an anchor (‘a’) element into the DOM instead of a button element:

Back to a functional component

Wonderful! We could stop there, but all the cool React kids say functional components are where it’s at and I’m not one to ignore peer pressure. The syntax for an arrow function definition is a little ugly but not too complex:

export const MyInfoButton = <As extends React.ElementType = 'button'>(props: MyInfoButtonProperties<As>) => (
    <Button {...props} 
        variant={props.variant || "light"}
        className={"mv-circular-button " + (props.className || "")}>
        <BsInfo/>
    </Button>
)

Or as a standard javascript function (which I personally find a little easier on the eyes):

export function MyInfoButton<As extends React.ElementType = 'button'>(props: MyInfoButtonProperties<As>) {
    return <Button {...props} 
        variant={props.variant || "light"}
        className={"mv-circular-button " + (props.className || "")}>
        <BsInfo/>
    </Button>
}

Putting it all together (with a handy little type for wrapping the bootstrap bits together):

//imports
import React from "react";
import { Button, ButtonProps } from "react-bootstrap";
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/helpers"
import { BsInfo } from 'react-icons/bs'
import './circular-button.css'

//handy type defintion to wrap up the replace+bsprefix bits of bootstrap
type BootstrapComponentProps<As extends React.ElementType, P> = ReplaceProps<As, BsPrefixProps<As> & P>

//our extended button properties
type NewButtonProps = {
    //any new properties we want to add go here
} & ButtonProps

//boot-strap-ified full button properties with all the bells and whistles
type MyInfoButtonProperties<As extends React.ElementType = 'button'> =
    BootstrapComponentProps<As, NewButtonProps>

//our button!
export function MyInfoButton<As extends React.ElementType = 'button'>(props: MyInfoButtonProperties<As>) {
    return <Button {...props} 
        variant={props.variant || "light"}
        className={"mv-circular-button " + (props.className || "")}>
        <BsInfo/>
    </Button>
}

Summary

An interesting little problem, and maybe a convincing answer to it, should anybody have the same question as me!

As I mentioned earlier, this kind of expose it all technique shouldn’t necessarily be avoided, but it should be approached with some thought. By blindly exposing everything in the new button type we may well have exposed some functionality that doesn’t quite work anymore due to our adjustments. This could clearly cause some confusion, but on the plus side we’ve ended up with something extremely flexible without lots of code to maintain.

Anyhoo, that’s it for my first React/TypeScript post. Stay tuned for more posts on this or other random things!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s