Polymorphic React Components that Actually Work in 2024

Full Code Examples

Credit: kriopd on GitHub

polymorphic-forwardRef-type-assertion.tsx
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

polymorphic-forwardRef-type-overloads.tsx
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

cast-forwardRef-function.tsx
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>
</>
);