Polymorphic React Components

Deep dive into building type-safe polymorphic React components.

Polymorphic React Components

What and Why?

Polymorphic Components are a particular type of component, useful when building multipurpose utility components, UI libraries, and design-systems.

The word polymorphic means:

Occurring in several different forms

In the context of React.js, a polymorphic component is one that can dynamically change the type of element rendered. That basically means, you can have a single component that acts as a div, span, header, or any other HTML tag or custom component, depending on your needs.

As you can imagine, that particular feature enables us to create very flexible and reusable components since they can adapt to different HTML elements or custom components while retaining their own functionalities and props.

Basic implementation

Let's start by creating a simple Container component that basically just adds some basic CSS to a given HTML element:

function Container({ children, as }) {
  const Element = as ?? "div";
  return (
    <Element className="max-w-5xl p-4 my-0 mx-auto">
      {children}
    </Element>
  );
}

As you can see, the as prop dictates what HTML tag is rendered in the browser, when rendering that component we get the following results:

<!-- input -->
<Container as="header">first</Container>
<!-- output -->
<header>first</header>

<!-- input -->
<Container as="section">second</Container>
<!-- output -->
<section>second</section>

So now you can reuse styles and behavior more easily, pretty cool right...

Current limitations

There are two issues with our basic implementation, let's explore these.

No auto-complete

For better reference, let's look at a the following Text component example:

function Text({ children, as }) {
  const Element = as ?? "span";
  return <Element>{children}</Element>;
}

The only props this component accepts are as and children, there’s no attribute support for even as props. For example, if we set as="a" for a link, we should also support passing an href to the component.

An easy solution may be to just go ahead and spread every other props passed as follows:

function Text({ children, as, ...otherProps }) {
  const Element = as ?? "span";
  return <Element {...otherProps}>{children}</Element>;
}

Although another issue arises, wrong attributes can also be passed down to the component, for example:

<Text as="whatever">Lorem ipsum</Text>

<Text as="h2" href="google.com">Lorem ipsum</Text>

No Ref support

Passing a ref to it doesn't work either, according to React docs:

Components that want to expose their DOM nodes have to opt in to that behavior.> A component can specify that it “forwards” its ref to one of its children.

That's when forwardRef comes into play.

Addressing current limitations

Next let's solve the two previously mentioned problems, this is where things start to get tricky.

Let's start coding

Utility types

Let's start by defining some utility types, these are going play a very important role, don't worry it may seem overwhelming at first glance but it will all start making sense as we go.

// types/polymorphic.ts

export type ObjectAny = Record<PropertyKey, unknown>;

interface OverridableTypeMap {
  props: ObjectAny;
  defaultComponent: React.ElementType;
}

/**
 * Remove properties `K` from `T`.
 * Distributive for union types.
 */
type DistributiveOmit<T, K extends keyof ObjectAny> = T extends ObjectAny
  ? Omit<T, K>
  : never;

/**
 * Like `T & U`, but using the value types from `U` where their properties overlap.
 */
export type Overwrite<T, U> = DistributiveOmit<T, keyof U> & U;

/**
 * Props defined on the component.
 */
type BaseProps<M extends OverridableTypeMap> = M["props"];

/**
 * Props if `as={Component}` is NOT used.
 */
export type DefaultComponentProps<M extends OverridableTypeMap> = BaseProps<M> &
  DistributiveOmit<
    React.ComponentPropsWithRef<M["defaultComponent"]>,
    keyof BaseProps<M>
  >;

/**
 * Own props of the component augmented with props of the root component.
 */
export type PolymorphicProps<
  TypeMap extends OverridableTypeMap,
  RootComponent extends React.ElementType,
> = TypeMap["props"] &
  DistributiveOmit<
    React.ComponentPropsWithRef<RootComponent>,
    keyof TypeMap["props"]
  > & {
    as?: React.ElementType;
  };

Implement component with prop types

Now let's define a more type safe Text component, these are the steps we may follow:

  1. Define what prop types our component will receive.
  2. Create an OverridableTypeMap like interface which will contain the previously defined props, and default element in case no as prop is provided.
  3. With the help of the utility types, define a Polymorphic Component Prop type, this type should also recive a generic which we will be calling Root.
  4. Implement the component's render function.

So this is what it should look like:

import { PolymorphicProps } from "../types/polymorphic.ts";

// Step 1
type Props = {
  size: "medium" | "small";
};

// Step 2
type TextTypeMap = {
  props: Props;
  defaultComponent: "span";
};

// Step 3
export type TextProps<
  Root extends React.ElementType = TextTypeMap["defaultComponent"],
> = PolymorphicProps<TextTypeMap, Root>;

// Step 4
export function Text(props: TextProps) {
  const { children, as, size = "medium", className, ...otherProps } = props;

  const Element = as ?? "span";

  const sizeVariants = {
    medium: "text-base leading-tight",
    small: "text-sm leading-normal",
  };

  const styles = `${sizeVariants[size]} ${className}`;

  return (
    <Element {...otherProps} className={styles}>
      {children}
    </Element>
  );
}

At this point, we should have a component that offers some level of prop type support and auto-complete, however we are not there yet so let's keep going.

Ref support

As you may already know, Ref support is being done by calling forwardRef to let your component receive a ref and forward it to a child component:

function TextImpl(props: TextProps, forwardedRef: React.ForwardedRef<Element>) {
  const { children, as, size = "medium", className, ...otherProps } = props;

  /* ... */

  return (
    <Element {...otherProps} className={styles} ref={forwardRef}>
      {children}
    </Element>
  );
}

export const Text = forwardRef(TextImpl);

Attribute support

Let's start by adding a couple more utility types, which will be used to override the forwardRef's default behavior and will allow components to dynamically change attribute types based on the root element provided (as prop)

// types/polymorphic.ts

/**
 * Props of the component if `as={Component}` is used.
 */
type OverrideProps<
  M extends OverridableTypeMap,
  C extends React.ElementType,
> = BaseProps<M> &
  DistributiveOmit<React.ComponentPropsWithRef<C>, keyof BaseProps<M>>;

/**
 * A component whose root component can be controlled via an `as` prop.
 * Adjusts valid props based on the type of `as`.
 */
export interface OverridableComponent<M extends OverridableTypeMap> {
  <C extends React.ElementType>(
    props: {
      /**
       * The component used for the root node.
       * Either a string to use a HTML element or a component.
       */
      as: C;
    } & OverrideProps<M, C>,
  ): JSX.Element | null;
  (props: DefaultComponentProps<M>): JSX.Element | null;
  propTypes?: ObjectAny;
}

We are almost there, as a final step we need to use the OverridableComponent interface on our Text component export as follows:

export const Text = forwardRef(TextImpl) as OverridableComponent<TextTypeMap>;

Concluding thoughts

Wrapping it up, we've explored one of may ways to create reusble and flexible React components that can switch between HTML elements thanks to the as prop, while addressing critical challenges such as auto-complete and ref support. In the end, we were able to build a robust Text component:

Demo

Full code example