DEV Community

Anthony Frehner
Anthony Frehner

Posted on • Updated on

Polymorphic React Button Component in Typescript

[Edited Oct 6 2021 with improved code due to feedback. See implementation section 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:

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

<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>
Enter fullscreen mode Exit fullscreen mode

Link

<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>
Enter fullscreen mode Exit fullscreen mode

External Link / Anchor Tag

<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>
Enter fullscreen mode Exit fullscreen mode

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.

<Button as='unstyled'>Unstyled</Button>
Enter fullscreen mode Exit fullscreen mode

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 and Iskander Samatov. 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 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 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? 🤷
  • 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 from Mae Capozzi 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!

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} />
  }
}
Enter fullscreen mode Exit fullscreen mode

Discussion (1)

Collapse
mcapoz profile image
Mae Capozzi

Great work on this Anthony! This is a gnarly problem, and your approach really helped my team get this working in a more elegant way than we had done it before.

I've got a couple of suggestions to improve this that will help you to get rid of the type guards and allow support for forwardRefs.

Suggestion #1
You should consider replacing JSX.IntrinsicElements['button'] with React.ButtonHTMLAttributes<HTMLButtonElement> and JSX.IntrinsicElements['a'] with React.AnchorHTMLAttributes<HTMLAnchorElement>. This will allow you to support forwardRefs.

Suggestion #2
You don't need the type guards if you spread the props inside of if statements where the TypeScript compiler knows the value of the as prop.

if (props.as === 'externalLink') {
  // Now TypeScript can infer that the rest props are all for an externalLink.
  const {as, ...rest} = props;
}
Enter fullscreen mode Exit fullscreen mode

Here it is all together.

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 = `${styleType ? styleType : ''} ${
    className ? className : ''
  }`

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

  throw new Error('could not determine the correct button type')
}

type OmitFromTypes = 'className' | 'styleType' | 'as'
Enter fullscreen mode Exit fullscreen mode