Polymorphic React Button Component in Typescript
Tags: react, javascript, typescript
Published: 2021-09-13 22:16:33

[Edited Oct 6 2021 with improved code due to feedback. See [implementation section](#implementation) for details]

## Polymorphic?

The goal of this article is to create a component that can be either a button OR a react-router `Link` component OR a native `<a>` (anchor) tag.

But first, let's define the word "polymorphic." From [Dictionary.com](https://www.dictionary.com/browse/polymorphic): 

> having more than one form or type

So when we call something a "polymorphic" component, it means that we can use the same component and it will have more than one form under the hood.

In this case, designers usually want a consistent look for interactive elements such as buttons and links, and developers want an easy interface to use these common styles, while also maintaining semantic and accessible HTML. 

## Use Cases / Examples

So we're going to make a component called `<Button />` that will allow someone to choose whether to use it as a button, a react-router `Link` component, or as an anchor for external links. And we want to have Typescript enforce and validate the correct props for each one.

For example, we want to be able to do the following:

### Button / Default

```tsx
<Button
  as='button'
  styleType='primary'
  onClick={(evt) => {
    // evt should be of type React.MouseEvent<HTMLButtonElement, MouseEvent>
    console.log(evt)
  }}
>
  hello!
</Button>

<Button
  // 'as' is optional, and will default to 'button'
  styleType='secondary'
  // allow other button attributes, such as 'type'
  type='button'
>
  hello!
</Button>
```

### Link

```tsx
<Button
  as='link'
  // 'to' is required since it's required in the Link component
  to='/test'
  styleType='primary'
  onClick={(evt) => {
    // evt should be of type React.MouseEvent<HTMLAnchorElement, MouseEvent>
    console.log(evt)
  }}
>
  hello!
</Button>
```

### External Link / Anchor Tag

```tsx
<Button
  as='externalLink'
  styleType='primary'
  onClick={(evt) => {
    // evt should be of type React.MouseEvent<HTMLAnchorElement, MouseEvent>
    console.log(evt)
  }}
  // href and other anchor attributes should be allowed
  href='/someurl'
  target='_blank'
  rel='noopener noreferrer'
>
  Link
</Button>
```

### Unstyled button

An unstyled button is occasionally used in designs where a designer wants some clickable text but without all the pomp and circumstance. Shouldn't be used very often.

```tsx
<Button as='unstyled'>Unstyled</Button>
```

## Implementation Notes:

Hopefully the use-cases above show how we want our component to be polymorphic. When it comes to the implementation, I originally started by referring to these wonderful articles by [Ben Ilegbodu](https://www.benmvp.com/blog/polymorphic-react-components-typescript/) and [Iskander Samatov](https://isamatov.com/polymorphic-components-react-typescript/). However, I kept running into issues with certain things like the rest parameters / props not being correctly typed or the `to` prop not being correctly recognized for `link` type Buttons. It was frustrating and I spent several days and [iterations](https://twitter.com/frehner_a/status/1436462652063195138?s=20) on it. 

Finally, I took a step back, tried to simplify as much as I could, and got it to work. It's not _as_ clean as I had hoped, but it's working and that's what matters, right? Anyway, some takeaways:

- ~~I had to use [type-predicate narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) to get the rest params to be correctly typed.  There's probably room to improve them there, but see the functions `isLinkProps`, `isButtonProps`, and `isAnchorProps`. Apparently it isn't enough for Typescript for us to key off of the `as` prop? :shrug:~~
- The anchor tag must explicitly have the `{rest.children}` part; the `jsx-a11y/anchor-has-content` ESLint plugin doesn't like it when you keep `children` as part of the `{...rest}` spread.
- ~~It took me awhile to figure out that I wanted `JSX.IntrinsicElements['button']` as the prop type definition; I had tried other things like `React.ComponentPropsWithoutRef<>` and `React.ElementType<>` combinations without much success for some reason - they would fail one of the test cases I outlined above. One day I'll understand Typescript better to tell you why.~~

## Implementation

Edited; thanks to [this wonderful comment](https://dev.to/mcapoz/comment/1ijlg) from [Mae Capozzi](https://dev.to/mcapoz) below, the typing for this component can be simplified! I can remove the type-predicate narrowing issues I described above by _not_ destructuring the `as` prop. Apparently TS likes that a lot more!

```tsx
import * as React from 'react'
import { Link } from 'react-router-dom'
import type { LinkProps } from 'react-router-dom'

type BaseProps = {
  children: React.ReactNode
  className?: string
  styleType: 'primary' | 'secondary' | 'tertiary'
}

type ButtonAsButton = BaseProps &
  Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps> & {
    as?: 'button'
  }

type ButtonAsUnstyled = Omit<ButtonAsButton, 'as' | 'styleType'> & {
  as: 'unstyled'
  styleType?: BaseProps['styleType']
}

type ButtonAsLink = BaseProps &
  Omit<LinkProps, keyof BaseProps> & {
    as: 'link'
  }

type ButtonAsExternal = BaseProps &
  Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof BaseProps> & {
    as: 'externalLink'
  }

type ButtonProps =
  | ButtonAsButton
  | ButtonAsExternal
  | ButtonAsLink
  | ButtonAsUnstyled

export function Button(props: ButtonProps): JSX.Element {
  const allClassNames = `${props.styleType ? props.styleType : ''} ${
    props.className ? props.className : ''
  }`

  if (props.as === 'link') {
    // don't pass unnecessary props to component
    const {className, styleType, as, ...rest} = props
    return <Link className={allClassNames} {...rest} />
  } else if (props.as === 'externalLink') {
    const {className, styleType, as, ...rest} = props
    return (
      <a
        className={allClassNames}
        // provide good + secure defaults while still allowing them to be overwritten
        target='_blank'
        rel='noopener noreferrer'
        {...rest}
      >
        {props.children}
      </a>
    )
  } else if (props.as === 'unstyled') {
    const {className, styleType, as, ...rest} = props
    return <button className={className} {...rest} />
  } else {
    const {className, styleType, as, ...rest} = props
    return <button className={allClassNames} {...rest} />
  }
}
```