Polymorphic React components with TypeScript
Polymorphic components enable customization of the underlying element (e.g., button, a, or div) while preserving the correct type for ref and other props. The most common use case when this is handy is a button that could render a or abutton element depending on what props are passed.
Here’s an example of implementation
import React, { forwardRef, ElementType, ComponentPropsWithRef } from "react";
type Props<T extends ElementType> = {
as?: T;
} & ComponentPropsWithRef<T>;
const Button = forwardRef(
<T extends ElementType = "button">(
{ as: Component = "button", ...props }: Props<T>,
ref: React.Ref<
T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : T
>
) => {
return (
<Component {...props} ref={ref}>
{props.children}
</Component>
);
}
);
Button.displayName = "Button";
Usage example
import React from 'react';
import Button from './Button';
const App = () => {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const anchorRef = React.useRef<HTMLAnchorElement>(null);
return (
<div>
<Button
ref={buttonRef}
onClick={() => console.log('Button clicked')}
>
Button
</PolymorphicButton>
<Button
as="a"
ref={anchorRef}
href="#"
onClick={() => console.log('Anchor clicked')}
>
Anchor
</Button>
</div>
);
};
export default App;
Conditional Types and Ref Handling
A key aspect of this implementation is the conditional type expression used for the ref prop.
React.Ref<T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : T>
This expression checks if T is a valid built-in HTML element type. If it is, it retrieves the corresponding prop types for that built-in element. If it isn't, it assumes T represents a custom component type and keeps the type as T. This ensures that the ref prop has the correct type for built-in and custom components.