Creating polymorphic components with React and TypeScript

Make your components accessible and customizable

Adding polymorphism to your React components will make them more versatile as consumers will be able to define the element rendered to the DOM.

There are important steps to follow when building these components so let’s get started!

Why do we need polymorphic components?

You may have noticed in some libraries like (Material UI or Chakra UI) some components have the option to allow an as or elementType prop. This is important for customization but also to improve accessibility by allowing us to use HTML semantic elements when rendering our components.

Adding an as prop to a React component

Let’s see how we could implement something similar to these libraries - we’ll start with a simple Text React component.

const Text = ({ as, children, ...rest }) => {
  const Component = as || "span";

  return <Component {...rest}>{children}</Component>;
};

export default function App() {
  return <Text>Text goes here</Text>; // Will render a span by default
}

So here’s our component. It accepts an as prop and children which means we can customize it straight away - let’s try that.

<Text as="main">Text goes here</Text>;
<Text as="p">Text goes here</Text>;
<Text as="section">Text goes here</Text>;
<Text as="a" href="www.example.com">Text goes here</Text>

What is wrong with this implementation?

Works like a charm! Oh, but wait…

<Text as="p" href="www.example.com">
    Text goes here
</Text>

Nothing is stopping us from passing an href to a paragraph, but we shouldn’t be allowed to use invalid HTML attributes right?

Here comes TypeScript

This is where our best friend TypeScript comes in.

We need to ensure that the Text component props are strongly typed so they can be statically checked during the development process.

The plan is to allow our as prop to accept any valid HTML element and then somehow determine which HTML attributes are valid based on that same element (e.g. an a tag should allow the href attribute).

Creating our types

Let’s start with setting up a type for the as prop.

import { ElementType } from "react";

type AsProp<T extends ElementType> = {
  /**
   * The HTML element used for the root node.
   */
  as?: T;
};

We’re setting the as prop as optional and using a generic type that will be determined by the value passed for as .

Our type T is restricted to types that extend ElementType from React i.e. any valid HTML elements.

import { ComponentPropsWithoutRef } from "react";

ComponentPropsWithoutRef<T>

We also want to include all other props such as style, className , ARIA tags, etc. But we don’t need to include the ref prop for passing through refs hence the use of ComponentPropsWithoutRef.

Omit<ComponentPropsWithoutRef<T>, keyof Props<T>>

We also want to look at T and remove any props that are already defined in Props so we’ll use keyof and Omit to achieve this.

Props<T> & Omit<ComponentPropsWithoutRef<T>, keyof Props<T>>

This also allows us to merge the properties of Props and T by removing any duplicates.

Our combined types should look something like this:

type TextProps<T extends ElementType> = PropsWithChildren<AsProp<T>> &
  Omit<ComponentPropsWithoutRef<T>, keyof AsProp<T>>;

Making our component polymorphic

Finally TextProps has everything we need for Text! Let’s put everything together and add the type to our Text component:

import {
  ElementType,
  ComponentPropsWithoutRef,
  PropsWithChildren
} from "react";

type AsProp<T extends ElementType> = {
  /**
   * The HTML element used for the root node.
   */
  as?: T;
};

type TextProps<T extends ElementType> = PropsWithChildren<AsProp<T>> &
  Omit<ComponentPropsWithoutRef<T>, keyof AsProp<T>>;

const Text = <T extends ElementType = "span">({
  as,
  children,
  ...rest
}: TextProps<T>) => {
  const Component = as || "span";

  return <Component {...rest}>{children}</Component>;
};

As our Props definition is generic our component should also be generic (and in our case, we also default the generic value to span).

The best thing about our setup is that now ...rest is also strongly typed.

So this should work:

<Text as="a" href="www.google.com">
    Text goes here
</Text>

But this should generate a typing error:

<Text as="main" href="www.google.com">
    Text goes here
</Text>

And we now have a polymorphic component! I hope you found this useful and make sure to check out the full example.