Create a Polymorphic Component with Typescript and React

A polymorphic component can change into any element based on how you use it. For example, you can create a Box component that will render a div, label, input, button, or any HTML element. This is typically done with an “as” or “is” prop:

<Box as="button" onClick={openLogin}>Login</Box>
// would render <button onclick="openLogin()">Login</button>

Why would you want to do this?

If you are creating reusable components for a library or a project, you most likely have a theme, color modes, and maybe utility props. You don’t want to add color modes and utility props every time you create a new component, this creates a lot of unnecessary boilerplate across your codebase.

A better way is to add all your themeing and enhanced props in the Box component, and then compose all other components based off Box. For example, if you want to use utility props, you set that up in the Box and use it everywhere.

// setup themeing and utility props for the Box
<Box p={2} mb={1} display="flex" variant="outline" bg="blue.500">
// create new components using Box
export function Button({children, ...rest}) {
 return <Box as="button" {...rest}>{children}</Box>
}
// now Button can use utility props, variants, etc.
<Button variant="outline" mb={2} bg="green.200">

Then if you want to make a change to your variants or props you do it once in the Box and it populates globally.

Creating a polymorphic component

Here’s the base of our polymorphic Box component.

const Box = ({ as, children, ...props }) => {
  const Element = as || 'div';
  return <Element {...props}>{children}</Element>;
};

This is a normal component that takes an “as” prop, and we use that as the HTML element that is rendered. So as=”input” will render an input element, as=”p” will render a paragraph tag, etc.

If you just build a Box component with no special typing, you will lose all of the great features of Typescript like intellisense and linting.

How do we make sure our polymorphic component still gives us all of Typescript’s great features?

Typescript Generics

That’s where generics come in. Generics allow you to tell the component what type you want to use. Here’s a simple example:

function simpleThing<Type>(arg: Type): Type {
  return arg;
}
// whatever type you put in is the same type you get out
simpleThing<number>(2)
simpleThing<string>('hello')

If we take this same concept to our polymorphic component, we can accept a generic HTML element, and change the intellisense based on that.

First, let’s create a simple type for our BoxProps.

type BoxProps = {
  as?: React.ElementType;
  children?: React.ReactNode;
}

Now let’s add a generic for the as prop.

type BoxProps<T extends React.ElementType> = {
  as?: T;
  children?: React.ReactNode;
}

The “T” can be anything, it’s just a variable. We could have used “Type”, “CustomEl” or anything we want. This tells Typescript we will be passing a React Element to the as prop, which can be HTML or a React component.

Right now all we have are the two props, but we want to see all valid attributes. To do that, we add a type React provides called ComponentPropsWithoutRef. That just means give me all the valid attributes like id, href, placeholder, etc. We do that by adding these types together with an ampersand, also called an intersection.

type BoxProps<T extends React.ElementType> = React.ComponentPropsWithoutRef<T> & {
  as?: T;
  children?: React.ReactNode;
};

Let’s add the BoxProps type to our Box component.

const Box = ({ as, children, ...rest }:BoxProps<T>) => {
  const Element = as || 'div';
  return <Element {...rest}>{children}</Element>;
};

We are almost done! Now we just have to tell Typescript that our Box is taking a generic, we do that using angle brackets.

const Box = <T extends React.ElementType>({ as, children, ...rest }:BoxProps<T>) => {
  const Element = as || 'div';
  return <Element {...rest}>{children}</Element>;
};

That’s it! we will now get intellisense based on the element we pass to the as prop.

Bonus: now do it with forwardRef

When building reusable components, you may want to use forwardRef to allow consumers of your components to get a DOM reference. This introduces some complexity with polymorphic components because of the way forwardRef works.

To add forwardRef to our polymorphic Box component, we just need to give the Box itself a polymorphic type.

type PolymorphicComponent = <T extends ElementType = 'div'>(
  props: BoxProps<T>
) => ReactElement | null;
const Box:PolymorphicComponent = <T extends React.ElementType>({ as, children, ...rest }:BoxProps<T>) => {
  const Element = as || 'div';
  return <Element {...rest}>{children}</Element>;
};

Now we wrap the whole thing in forwardRef, add our ref prop, and we’re done.

const Box: PolymorphicComponent = forwardRef(
  <T extends ElementType>(props: BoxProps<T>, ref: PolymorphicRef<T>) => {
    const { as, children, ...rest } = props;
    const Element = as || 'div';
    return (
      <Element ref={ref} {...rest}>
        {children}
      </Element>
    );
  }
);

You’ve now got a polymorphic Box component with forwardRef that you can use in your own reusable React component library.


Posted

in

by

Tags: