Polymorphic React Components
Deep dive into building type-safe 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.
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:
- Define what prop types our component will receive.
- Create an
OverridableTypeMap
like interface which will contain the previously defined props, and default element in case noas
prop is provided. - 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
. - 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
- Text component: Text.tsx
- Utility types: polymorphic.ts