[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>
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>
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>
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>
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 functionsisLinkProps
,isButtonProps
, andisAnchorProps
. Apparently it isn't enough for Typescript for us to key off of theas
prop? 🤷- The anchor tag must explicitly have the
{rest.children}
part; thejsx-a11y/anchor-has-content
ESLint plugin doesn't like it when you keepchildren
as part of the{...rest}
spread. It took me awhile to figure out that I wantedJSX.IntrinsicElements['button']
as the prop type definition; I had tried other things likeReact.ComponentPropsWithoutRef<>
andReact.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} />
}
}
Top comments (3)
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']
withReact.ButtonHTMLAttributes<HTMLButtonElement>
andJSX.IntrinsicElements['a']
withReact.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.
Here it is all together.
Thank you, amazing.
another example,
github.com/react-bootstrap/react-b...