Polymorphic React Components that Actually Work in 2024
Full Code Examples
Credit: kriopd on GitHub
import { forwardRef, type ComponentPropsWithRef, type ElementType, type JSX, type JSXElementConstructor, type Ref,} from "react";
type IntrinsicAttributes<E extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = JSX.LibraryManagedAttributes<E, ComponentPropsWithRef<E>>;
export interface BoxOwnProps<E extends ElementType = ElementType> { as?: E;}
export type BoxProps<E extends ElementType> = BoxOwnProps<E> & Omit<IntrinsicAttributes<E>, keyof BoxOwnProps>;
export const Box = forwardRef(({ as: Element = "div", ...props }: BoxOwnProps, ref: Ref<Element>) => ( <Element ref={ref} {...restProps} />)) as <E extends ElementType = "div">(props: BoxProps<E>) => JSX.Element;
// Example usage:
export const App = () => ( <> {/* ERROR: property 'href' does not exist on type 'IntrinsicAttributes & PolymorphicAsProp<"div"> */} <Box href="/">đ˘</Box> <Box as="a" href=""> {" "} đ </Box> </>);
Credit: Radix Polymorphic Utility
import { forwardRef, type ComponentPropsWithRef, type ElementType, type ForwardRefExoticComponent, type JSX, type ReactElement,} from "react";
type Merge<P1 = object, P2 = object> = Omit<P1, keyof P2> & P2;type MergeProps<E, P = object> = P & Merge<E extends ElementType ? ComponentPropsWithRef<E> : never, P>;
interface ForwardRefComponent<IntrinsicElementString, OwnProps = object> extends ForwardRefExoticComponent< MergeProps<IntrinsicElementString, OwnProps & { as?: IntrinsicElementString }> > { <As extends keyof JSX.IntrinsicElements>(props: MergeProps<As, OwnProps & { as: As }>): ReactElement | null;
<As extends ElementType<unknown>, _AsWithProps = As extends ElementType<infer P> ? ElementType<P> : never>( props: MergeProps<_AsWithProps, OwnProps & { as: _AsWithProps }>, ): ReactElement | null;}
const Box = forwardRef(({ as: Element = "div", ...props }, forwardedRef) => ( <Element ref={forwardedRef} {...props} />)) as ForwardRefComponent<"div">;
// Example usage:export const App = () => ( <> {/* ERROR: Property 'href' does not exist on type 'IntrinsicAttributes & object & { as?: "div"; } */} <Box href="/">đ˘</Box> <Box as="a" href=""> đ </Box> ; </>);
Credit: Nashe Omirro
import { forwardRef, type ComponentPropsWithRef, type ElementType, type ForwardRefExoticComponent, type ForwardRefRenderFunction, type ReactElement,} from "react";
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
type Merge<A, B> = Omit<A, keyof B> & B;type DistributiveMerge<A, B> = DistributiveOmit<A, keyof B> & B;
export type AsProps< Component extends ElementType, PermanentProps extends object, ComponentProps extends object,> = DistributiveMerge<ComponentProps, PermanentProps & { as?: Component }>;
export type PolymorphicWithRef< Default extends OnlyAs, Props extends object = {}, OnlyAs extends ElementType = ElementType,> = <T extends OnlyAs = Default>(props: AsProps<T, Props, ComponentPropsWithRef<T>>) => ReactElement | null;
export type PolyForwardComponent< Default extends OnlyAs, Props extends object = {}, OnlyAs extends ElementType = ElementType,> = Merge< ForwardRefExoticComponent<Merge<ComponentPropsWithRef<Default>, Props & { as?: Default }>>, PolymorphicWithRef<Default, Props, OnlyAs>>;
export type PolyRefFunction = < Default extends OnlyAs, Props extends object = {}, OnlyAs extends ElementType = ElementType,>( Component: ForwardRefRenderFunction<any, Props & { as?: OnlyAs }>,) => PolyForwardComponent<Default, Props, OnlyAs>;
const polymorphicForwardRef = forwardRef as PolyRefFunction;
interface BoxProps { open?: boolean;}
const Box = polymorphicForwardRef<"div", BoxProps>(({ as: Element = "div", ...props }, ref) => ( <Element ref={ref} {...props} />));
const App = () => ( <> {/* ERROR: Property 'href' does not exist on type 'IntrinsicAttributes */} <Box href="/">đ˘</Box> <Box as="a" href=""> đ </Box> </>);
Why Do These Work and Others Donât?
If youâre trying to create a React component library in 2024 and search for âReact Polymorphic forwardRef TypeScriptâ,
youâll get a handful of answers that unfortunately donât work with the latest types for React and the latest version of TypeScript.
Or youâll get a bunch of polymorphic components that donât include forwardRef
.
Forward Ref can be a wonky but nessecary API, especially for library creators who are trying to support a wide audience with their components. If youâre searching for a soltion like this, youâre likely in this category.
In this post, weâre going to ignore polymorphic components that donât need forwardRef
. Setting that up is a task TypeScript is well suited
for and thereâs lots of blog posts and resources to set that up.
The problem stems from the types of forwardRef
: Itâs already a generic interface that needs to accept a ref
function or object and a props
object:
function forwardRef<Ref, Props = {}>( render: ForwardRefRenderFunction<Ref, Props>,): ForwardRefExoticComponent<PropsWithoutRef<Props> & RefAttributes<Ref>>;
interface ForwardRefRenderFunction<Ref, Props = {}> { (props: Props, ref: ForwardedRef<Ref>): ReactNode; displayName?: string | undefined;}
There just isnât a syntax for telling this function interface that we need to do something to Ref
and Props
above when another generic within Props
changes.
We really need to do something like this:
const GenericForwardRef = forwardRef<Element extends ElementType = 'div', RefType, Props<Element>>( ({ as: Component, ...props }, forwardedRef) => <Component {...props} />,);
But we canât, thatâs not the interface for the function and the types already seem complicated enough already.
Stricter type narrowing in TypeScript 5.0 made previous solutions not work as well. Youâll see some version of this on the internet where the callback is given a generic instead of the forwardRef
function that no longer works:
type RenderFunctionType = <C extends ElementType = "span">(props: ComponentProps<C>) => ReactElement | null;
// Type 'ForwardRefExoticComponent<Omit<TextProps<ElementType<any>>, "ref"> & RefAttributes<unknown>>' is not assignable to type 'TextComponent'.// Type 'ReactNode' is not assignable to type 'ReactElement<any, string | JSXElementConstructor<any>> | null'.// Type 'undefined' is not assignable to type 'ReactElement<any, string | JSXElementConstructor<any>> | null'.ts(2322)const Text: RenderFunctionType = forwardRef( <Element extends ElementType = "span">( { as, ...props }: ComponentProps<Element>, ref?: PolymorphicRef<Element>, ) => { const Component = as || "span";
return <Component ref={ref} {...props} />; },);
Need To Assert the Component or Function
All of these solutions cast the return value of our render function or the forwardRef function itself, which I think is a perfect use of a type assertion:
We, as the developers, know more about the forwardRef
function that weâre composing than TypeScript can by itself, so weâre overriding it with the type information we actually want to use.
Why Is This So Hard?
Well itâs because forwardRef
is kind of weird. One of the things that youâll end up trying as a React developer is to pass a ref
as a prop in a function component,
and youâll recieve a handy runtime error that youâre not allowed to do that, or youâll notice that the ref ends up as undefined
. This was possible with class components and the createRef
API, but not functional components. Why?
So far I havenât found a satisfying answer, but I would imagine that it stems from the need for React to track which refs are referencing which DOM nodes during runtime.
Since refs are undefined
or null
until runtime, I assume React needs a specific way to handle targeting nodes that canât be handled like other pieces of data.
Because React props are immutable and refs arenât really assigned until after mount, passing refs via props violates that principle and would likely cause re-render weirdness.
From what I can gather, Solid & its compiler can track all of that at compile time, and treats refs just like any other prop. Or maybe itâs because Solid components return native HTML elements instead of JavaScript? Unclear.
It was very challenging to find information as to why this pattern needs to exist outside of âcompatibility between class & functionc componentsâ. One of the best ways to learn something is to put out wrong information on the internet and have someone correct you; please correct me if you know the answer to this đ.
Regardless, being able to treat refs as if they were just any other piece of data would make this significantly easier. Maybe React Forget will make these types and usage of forwardRef
less painful by creating refs at compile time.
Composition and asChild
Youâll notice one of the solutions above came from a now depreciated Radix package, theyâre transitioned to a more compositional approach to polymorphism:
Their asChild
prop is a clever use of how React creates props to essentially merge the props of a parent component onto the props of a child component. The result is a pattern that creates a compositional approach over the configurable approach from the as
prop method:
export default () => ( <Button asChild> <a href="/contact">Contact</a> </Button>);
The nice thing about this is that it discards the need to check a potentially conditional type on a single element: TypeScript will just check the types on each individual component as normal. If there are conflicts between props on the parent & child, youâll usually get runtime errors for bad attributes on HTML elements, or you can create your own errors for custom components and props.
While youâll lose the static checks that the as
prop provides, youâre still getting a good-enough static checking experience from needing to check the individual elements.
I like this pattern as well, and am a big fan of the compositional nature of it.
Bonus Bits and Conclusion
I personally really like Nashe Omirroâs solution & accompanying type package to assert forwardRef
to an interface that handles polymorphism.
I think itâs pretty easy to read and would be something I would be comfortable giving to other devs on a team to compose components with.
Their package isnât on TypeScript 5.0 or React 18 or higher at the time of writing, but my tests with in on those versions were fine.
A nice optimization they came up with as well is to speed up ComponentPropsWithRef
by checking all unions within ElementType
outside of a conditional type, which sped up the type checking by a decent chunk:
type ComponentPropsWithRef<T extends ElementType> = PropsWithRef< T extends new (props: infer P) => Component<any, any> ? PropsWithoutRef<P> & RefAttributes<InstanceType<T>> : ComponentProps<T>>;
Lastly, all of these solutions support passing in other components from potentially other libraries that also have forwarded refs in those components:
import { Link } from "react-router-dom";
const App = () => ( <> <Box as="a" href=""> đ </Box> <Box as={Link} to="/"> đ§ </Box> </>);