Polymorphic components with React and Typescript

What are Polymorphic components?

If you've ever tried any React component libraries like Material UI, Mantine UI or Chakra UI.

You might have used the as prop, which is available in most of the library components, the general idea behind this prop is to allow the consumers of your library to pass a custom element or another component that can be rendered instead of a hard-coded value.

There are plenty of confusing definitions out there when it comes to this topic, though the way I like to think about it is this:-

A polymorphic component is a design pattern that allows a component to act in more than one way depending on the UI requirements without breaking any accessibility or Type checking. So a Button might work as a Navigation-link or vice versa.

A key thing to note here is that it's possible to build them without Typescript, however it is rudimentary and even incomplete without the type checking.

Let's see an example of a polymorphic Text and Button component.

Section.tsx

_19
import Link from "react-router-dom"
_19
_19
function Section(){
_19
return (
_19
<section>
_19
<Text as="h1" weight="bold">
_19
I am a h1 heading
_19
</Text>
_19
_19
<Text as="p" weight="medium">
_19
I am a paragraph
_19
</Text>
_19
_19
<Button as={Link} to="https://sidwebworks.com">
_19
I am a button rendered as a Link component
_19
</Button>
_19
</section>
_19
)
_19
}

At first glance, the Text component looks super simple. All it takes is a tag name and renders that element with the given content, so what gets rendered on the page is this

index.html

_10
<section>
_10
<h1 style="font-weight: bold;">I am a h1 heading</h1>
_10
_10
<p style="font-weight: medium;">I am a paragraph</p>
_10
_10
<a href="https://sidwebworks.com" className="btn">
_10
I am a button rendered as a Link component
_10
</a>
_10
</section>

The Button component is where things start to become a little tricky, and this is where we see the true power of polymorphism.

button and a both are semantically very different, eg. a button does not accept an href attribute whereas an a tag does.

Now let's build one from scratch.

Project setup

You can use any frontend tooling like CRA, Snowpack or Vite

I will use Vite, to quickly create a new React + Typescript application with Vite I can do

Terminal

_10
# with NPM
_10
npm create vite demo --template react-ts
_10
_10
# with Yarn
_10
yarn create vite/app demo --template react-ts

Creating the Text component

Our Text component should be able do the following

  • Accept an as prop.
  • Type check props against attributes of the element passed in as prop.
  • Accept a weight prop to customise the font-weight of the element.

Here is how our basic Text component would look like

Text.tsx

_11
import React, { ComponentPropsWithoutRef } from 'react';
_11
_11
type TextProps = ComponentPropsWithoutRef<'span'>;
_11
_11
const Text = ({ children, className, ...rest }: TextProps) => {
_11
return (
_11
<span className={className} {...rest}>
_11
{children}
_11
</span>
_11
);
_11
};

if it's your first time using ComponentPropsWithoutRef utility type, no worries.

All is does is to take a React.ElementType eg: span and return an object type of all the available attributes for that element.

Cool, the basic component stuff is done but! it's still not Polymorphic.

Supporting the as prop

We will check if the as prop is provided, if it is then render that as the Component otherwise default to a regular span tag.

Text.tsx

_14
import React, { ComponentPropsWithoutRef, ElementType } from "react"
_14
_14
type TextProps = ComponentPropsWithoutRef<"span"> & { as?: ElementType }
_14
_14
const Text = ({ children, className, as, ...rest }: TextProps) => {
_14
_14
const Component = as || "span"
_14
_14
return (
_14
<Component className={className} {...rest}>
_14
{children}
_14
</Component>
_14
)
_14
}

So now if we try it like this,


_10
<Text as="a" href="http://sidwebworks">
_10
Hello world
_10
</Text>

Typescript Error

Typescript screams at us with this weird error. Let me simplify it for you.

All it's trying to say is href does not exists on TextProps which is quite right as we passed span to the ComponentPropsWithoutRef utility and spans don't have a href attribute.

Let's fix this.

Improving type support for as prop

The problem we now have is, when the as prop changes, the value passed to ComponentPropsWithoutRef is still hard-coded.

Text.tsx

_13
import React, { ComponentPropsWithoutRef, ElementType } from 'react';
_13
_13
type TextProps = ComponentPropsWithoutRef<'span'> & { as?: ElementType };
_13
_13
const Text = ({ children, className, as, ...rest }: TextProps) => {
_13
const Component = as || 'span';
_13
_13
return (
_13
<Component className={className} {...rest}>
_13
{children}
_13
</Component>
_13
);
_13
};

So we need some way to pass a dynamic value in there which changes as we update our as prop value.

Luckily for us Typescript offers a feature called Generics which can help us here.

A Generic is essentially a type value which can be passed around to other types and functions, kind of like variables which only Types can access.

So let's Genericify our component :)

In order to do that, it would be better to have a utility type like a PolymorphicPropsWithoutRef which takes 2 generic types C (as prop) and P (custom props)

We're just making this utility so we can have some reusability in our code.

Writing a utility type

Text.tsx

_10
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react';
_10
_10
type AsProp<C extends ElementType> = {
_10
as?: C;
_10
};
_10
_10
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> &
_10
AsProp<C> &
_10
ComponentPropsWithoutRef<C>;

Umm so what the hell does this type even do? Let's break it down a bit.

Text.tsx

_10
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
_10
_10
type AsProp<C extends ElementType> = {
_10
as?: C
_10
}
_10
_10
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>

In the above highlighted code, like the name says we create a type for the as prop that takes in a generic C which extends the ElementType

Extending other types like this is called as Generic constrains meaning it will only accept a value that satifies that given constrain.

Text.tsx

_10
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
_10
_10
type AsProp<C extends ElementType> = {
_10
as?: C
_10
}
_10
_10
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>

Next, we create our main utility PolymorphicPropsWithoutRef, which again takes in 2 generics C for the ElementType and an optional type P for custom props which we default to as an empty object.

In this type we make use of 3 other utility types and compose them together.

These utilities are :-

  • PropsWithChildren: comes from React, adds the children prop.
  • AsProp: we made this! it adds the as prop.
  • ComponentPropsWithoutRef: comes from React, adds the element's HTML attributes.

Nice! let's use it back in our Text Component.

Text.tsx

_21
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
_21
_21
type AsProp<C extends ElementType> = {
_21
as?: C
_21
}
_21
_21
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>
_21
_21
_21
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C>
_21
_21
const Text = <As extends ElementType = "span">({ children, className, as, ...rest }: TextProps<As>) => {
_21
_21
const Component = as || "span"
_21
_21
return (
_21
<Component className={className} {...rest}>
_21
{children}
_21
</Component>
_21
)
_21
}

Works like a charm! Lets add our weight prop.

Adding a custom size prop

So we will have some default font weights object that our component will accept, and then use the keyof operator to extract a union type from that object.

Then we can pass it to our polymorphic utility type

Text.tsx

_12
const WEIGHTS = {
_12
xs: '100',
_12
sm: '200',
_12
md: '400',
_12
lg: '600',
_12
};
_12
_12
type FontWeights = keyof typeof WEIGHTS
_12
_12
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C, {
_12
weight: FontWeights
_12
}>

Next we can merge the style object coming from the props and add an fontWeight key, which will be the weight prop's value

Text.tsx

_31
const WEIGHTS = {
_31
xs: '100',
_31
sm: '200',
_31
md: '400',
_31
lg: '600',
_31
};
_31
_31
type FontWeights = keyof typeof WEIGHTS
_31
_31
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C, {
_31
weight: FontWeights
_31
}>
_31
_31
const Text = <As extends ElementType = "span">({
_31
children,
_31
className,
_31
as,
_31
weight = "md",
_31
style,
_31
...rest
_31
}: TextProps<As>) => {
_31
const Component = as || "span";
_31
_31
const _style = { ...style, fontWeight: WEIGHTS[weight] };
_31
_31
return (
_31
<Component className={className} style={_style} {...rest}>
_31
{children}
_31
</Component>
_31
);
_31
};

That's it

Now you can use these same techniques to create other polymorphic components like Button, Icon etc.