react-polymorphic-types is a library that enables the creation of zero-runtime polymorphic component definitions in React.
When building design systems or reusable UI components in React, you may come across the need for polymorphic components. A polymorphic component is a versatile component that can render different underlying HTML elements or custom components based on a prop.
A React polymorphic component provides flexibility to the consumer, allowing them to specify the desired element or component type to be rendered using a prop.
For example, let's consider a polymorphic heading component:
import{createElement,ElementType,PropsWithChildren,ComponentProps}from'react';// Define the props for the polymorphic heading componenttypeHeadingProps<TextendsElementType='h1'>=PropsWithChildren<{as?: T;}&ComponentProps<T>>;// Define the polymorphic heading componentexportconstHeading=<TextendsElementType='h1'>({ as ='h1', children, ...rest}: HeadingProps<T>)=>createElement(as,rest,children);In the above example, the Heading component can render different heading levels (h1, h2, h3, etc.) based on the as prop. By default, it renders as an h1 element.
You can use the Heading component in your application like this:
constApp=()=>(<article><Heading>My Main Headline</Heading><Headingas='h2'>A Subtitle</Heading><p>A description</p></article>);In this case, the same Heading component is used to render two different semantic tags, h1 and h2, allowing you to control the heading level and maintain consistency across your application.
Polymorphic components provide an elegant solution for building flexible and reusable UI components in React, enabling you to create a cohesive design system with consistent semantics.
The use of the as attribute can become complex when adding constraints to your rendered markup or when using third-party components. Declaring polymorphic types for each component can also be a tedious task that you may want to abstract.
With @axa-ch/react-polymorphic-types, you can easily add constraints to your polymorphic React components and avoid redundant type definitions.
Install the TypeScript types via npm:
npm i @axa-ch/react-polymorphic-types -DThe following recipes provide a starting point for creating polymorphic components. You can copy and modify them according to your requirements.
Basic Example
This example showcases a simple polymorphic heading element. It allows you to independently define its size and markup using props.
import{ComponentPropsWithoutRef,createElement,ElementType}from'react';import{PolymorphicProps}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstHeadingDefaultElement: ElementType='h1';// List of allowed HTML elements that can be passed via the "as" propexporttypeHeadingAllowedElements=typeofHeadingDefaultElement|'h2'|'h3'|'h4'|'h5'|'h6';exporttypeHeadingSizes=1|2|3|4|5|6;// Component-specific propsexporttypeHeadingOwnProps<TextendsHeadingAllowedElements>=ComponentPropsWithoutRef<T>&{size?: HeadingSizes;};// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeHeadingProps<TextendsHeadingAllowedElements=typeofHeadingDefaultElement>=PolymorphicProps<HeadingOwnProps<T>,T,HeadingAllowedElements>;exportconstHeading=<TextendsHeadingAllowedElements>({
as =HeadingDefaultElement,
size,
className,
children,
...rest}: HeadingProps<T>)=>createElement(as,{
...rest,className: `${className} size-${size||1}`,},children,);You can use the Heading component in your application as shown below:
constApp=()=>(<article><Headingas='h1'size={2}>
My Main Headline
</Heading><Headingas='h2'size={5}>
A Subtitle
</Heading>{/* The following component will throw a TypeScript error because 'div' elements are not allowed here */}<Headingas='div'size={5}>
A Subtitle
</Heading><p>A description</p></article>);Basic Example with Ref
This example is similar to the previous one, but it also allows the use of React refs.
import{ComponentPropsWithoutRef,createElement,ElementType,forwardRef}from'react';import{PolymorphicProps,PolymorphicForwardedRef}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstHeadingDefaultElement: ElementType='h1';// List of allowed HTML elements that can be passed via the "as" propexporttypeHeadingAllowedElements=typeofHeadingDefaultElement|'h2'|'h3'|'h4'|'h5'|'h6';exporttypeHeadingSizes=1|2|3|4|5|6;// Component-specific propsexporttypeHeadingOwnProps<TextendsHeadingAllowedElements>=ComponentPropsWithoutRef<T>&{size?: HeadingSizes;};// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeHeadingProps<TextendsHeadingAllowedElements>=PolymorphicProps<HeadingOwnProps<T>,T,HeadingAllowedElements>;constHeadingInner=<TextendsHeadingAllowedElements>({ as =HeadingDefaultElement, size, className, children, ...rest}: HeaadingProps<T>,// notice the use of the PolymorphicForwardedRef type hereref: PolymorphicForwardedRef<T>,)=>createElement(as,{
...rest,
ref,className: `${className} size-${size||1}`,},children,);// Forward refs with generics is tricky// see also https://fettblog.eu/typescript-react-generic-forward-refs/exportconstHeading=forwardRef(HeadingInner)asunknownas<TextendsHeadingAllowedElements>(props: HeadingProps<T>&{ref?: PolymorphicForwardedRef<T>},)=>ReturnType<typeofHeadingInner>;Using the @axa-ch/react-polymorphic-types types will allow you to automatically infer the proper ref DOM node.
constApp=()=>{// The use of HTMLHeadingElement type is safeconstref=useRef<HTMLHeadingElement|null>(null);return(<Headingref={ref}as='h2'/>);};React 19 Example with Ref
This example is similar to the previous one, but with React 19 the refs forwarding got much easier
import{ComponentPropsWithoutRef,createElement,ElementType,forwardRef}from'react';import{PolymorphicProps,PolymorphicForwardedRef}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstHeadingDefaultElement: ElementType='h1';// List of allowed HTML elements that can be passed via the "as" propexporttypeHeadingAllowedElements=typeofHeadingDefaultElement|'h2'|'h3'|'h4'|'h5'|'h6';exporttypeHeadingSizes=1|2|3|4|5|6;// Component-specific propsexporttypeHeadingOwnProps<TextendsHeadingAllowedElements>=ComponentPropsWithoutRef<T>&{size?: HeadingSizes;ref?: PolymorphicForwardedRef<T>;};// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeHeadingProps<TextendsHeadingAllowedElements>=PolymorphicProps<HeadingOwnProps<T>,T,HeadingAllowedElements>;constHeading=<TextendsHeadingAllowedElements>({
as =HeadingDefaultElement,
size,
className,
ref,
children,
...rest}: HeadingProps<T>)=>createElement(as,{
...rest,
ref,className: `${className} size-${size||1}`,},children,);Using the @axa-ch/react-polymorphic-types types will allow you to automatically infer the proper ref DOM node.
constApp=()=>{// The use of HTMLHeadingElement type is safeconstref=useRef<HTMLHeadingElement|null>(null);return(<Headingref={ref}as='h2'/>);};Basic Example with Memo
This example shows the use of React.memo with a polymorphic component.
import{ComponentPropsWithoutRef,createElement,ElementType,memo}from'react';import{PolymorphicProps}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstHeadingDefaultElement: ElementType='h1';// List of allowed HTML elements that can be passed via the "as" propexporttypeHeadingAllowedElements=typeofHeadingDefaultElement|'h2'|'h3'|'h4'|'h5'|'h6';exporttypeHeadingSizes=1|2|3|4|5|6;// Component-specific propsexporttypeHeadingOwnProps<TextendsHeadingAllowedElements>=ComponentPropsWithoutRef<T>&{size?: HeadingSizes;};// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeHeadingProps<TextendsHeadingAllowedElements>=PolymorphicProps<HeadingOwnProps<T>,T,HeadingAllowedElements>;constHeadingInner=<TextendsHeadingAllowedElements>({
as =HeadingDefaultElement,
size,
className,
children,
...rest}: HeadingProps<T>)=>createElement(as,{
...rest,className: `${className} size-${size||1}`,},children,);// Memo with generics is tricky// see also https://fettblog.eu/typescript-react-generic-forward-refs/exportconstHeading=memo(HeadingInner)as<TextendsHeadingAllowedElements>(props: HeadingProps<T>,)=>ReturnType<typeofHeadingInner>;The above component can be consumed without any additional overhead as follows:
constApp=()=>(<><Headingas='h2'/></>);Exotic Component Example
Polymorphic exotic components allow you to use either DOM nodes or custom rendering functions for your HTML.
import{ComponentPropsWithoutRef,createElement,ElementType,ExoticComponent}from'react';import{PolymorphicExoticProps,PolymorphicProps}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstContainerDefaultElement: ElementType='div';// List of allowed HTML elements that can be passed via the "as" propexporttypeContainerAllowedDOMElements=typeofContainerDefaultElement|'article'|'section';exporttypeContainerAllowedElements=ContainerAllowedDOMElements|ExoticComponent;// Component-specific propsexporttypeContainerOwnProps<TextendsContainerAllowedDOMElements>=ComponentPropsWithoutRef<T>;// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeContainerProps<TextendsContainerAllowedElements>=TextendsContainerAllowedDOMElements
? PolymorphicProps<ContainerOwnProps<T>,T,ContainerAllowedDOMElements>
: PolymorphicExoticProps<ContainerOwnProps<ContainerAllowedDOMElements>,T,ContainerAllowedDOMElements>;exportconstContainer=<TextendsContainerAllowedElements>({
as =ContainerDefaultElement,
className,
children,
...rest}: ContainerProps<T>)=>createElement(as,{
...rest,
className,},children,);The above component works with straight HTML nodes or with external exotic components like, for example, the ones provided by framer-motion.
import{motion}from'framer-motion';constApp=()=>(<><Containeras='div'/>{/* Notice that the exotic props here will be automatically inferred */}<Containeras={motion.article}layout/></>);Exotic Component Example with Ref
Polymorphic exotic components that use refs are slightly more complex and require some additional code to work properly.
import{ComponentPropsWithoutRef,createElement,ElementType,ExoticComponent,forwardRef}from'react';import{PolymorphicProps,PolymorphicForwardedRef,PolymorphicExoticProps}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstContainerDefaultElement: ElementType='div';// List of allowed HTML elements that can be passed via the "as" propexporttypeContainerAllowedDOMElements='div'|'article'|'section';exporttypeContainerAllowedElements=ContainerAllowedDOMElements|ExoticComponent;// Component-specific propsexporttypeContainerOwnProps<TextendsContainerAllowedDOMElements>=ComponentPropsWithoutRef<T>;// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeContainerProps<TextendsContainerAllowedElements>=TextendsContainerAllowedDOMElements
? PolymorphicProps<ContainerOwnProps<T>,T,ContainerAllowedDOMElements>
: PolymorphicExoticProps<ContainerOwnProps<ContainerAllowedDOMElements>,T,ContainerAllowedDOMElements>;// Forwarded ref componentconstContainerInner=<TextendsContainerAllowedElements>({ as =ContainerDefaultElement, className, children, ...rest}: ContainerProps<T>,ref: PolymorphicForwardedRef<T>,)=>createElement(as,{
...rest,
ref,
className,},children,);// Forward refs with generics is tricky// see also https://fettblog.eu/typescript-react-generic-forward-refs/exportconstContainer=forwardRef<ContainerAllowedElements>(ContainerInner)as<TextendsContainerAllowedElements>(props: ContainerProps<T>&{ref?: PolymorphicForwardedRef<T>},)=>ReturnType<typeofContainerInner>;With the above example, DOM nodes will be automatically inferred, including when using third-party exotic rendering functions.
import{motion}from'framer-motion';constApp=()=>{constdiv=useRef<HTMLDivElement|null>(null);// Article and other HTML5 tags are just of type HTMLElementconstarticle=useRef<HTMLElement|null>(null);return(<><Containerref={div}as='div'/><Containerref={article}as={motion.article}layout/></>);};Complex Exotic/Functional Component Example
This example combines multiple rendering strategies for your component to allow maximum flexibility for its consumers.
// We need to infer the functional component properties so 'any' is used in this case// You can also add strict types for your functional components, but it will reduce flexibilityimport{ComponentPropsWithoutRef,createElement,ElementType,ExoticComponent,FC}from'react';import{PolymorphicFunctionalProps,PolymorphicExoticProps,PolymorphicProps}from'@axa-ch/react-polymorphic-types';// Default HTML element if the "as" prop is not providedexportconstContainerDefaultElement: ElementType='div';// List of allowed HTML elements that can be passed via the "as" propexporttypeContainerAllowedDOMElements='div'|'article'|'section';exporttypeContainerAllowedElements=ContainerAllowedDOMElements|ExoticComponent|FC<any>;// Component-specific propsexporttypeContainerOwnProps<TextendsContainerAllowedDOMElements>=ComponentPropsWithoutRef<T>;// Extend own props with others inherited from the underlying element type// Own props take precedence over the inherited onesexporttypeContainerProps<TextendsContainerAllowedElements>=TextendsContainerAllowedDOMElements
? PolymorphicProps<ContainerOwnProps<T>,T,ContainerAllowedDOMElements>
: TextendsFC<any>
? PolymorphicFunctionalProps<ContainerOwnProps<ContainerAllowedDOMElements>,T,ContainerAllowedDOMElements>
: PolymorphicExoticProps<ContainerOwnProps<ContainerAllowedDOMElements>,T,ContainerAllowedDOMElements>;exportconstContainer=<TextendsContainerAllowedElements>({
as =ContainerDefaultElement,
className,
children,
...rest}: ContainerProps<T>)=>createElement(as,{
...rest,
className,},children,);Let's see how we can use the above component with all its possible rendering options:
import{motion}from'framer-motion';typeFooProps=ComponentPropsWithoutRef<'div'>&{size: 'small'|'large';name: string};constFoo: FC<FooProps>=({ className, size ='large', ...rest})=>(<div{...rest}className={`${className} the-foo ${size}`}/>);constApp=()=>(<><Containeras='div'/><Containersize='small'name='foo'as={Foo}/><Containeras={motion.div}layoutanimate/></>);This project wouldn't exist without react-polymorphic-types