Polymorphic React Components that Actually Work in 2024
Full Code Examples
Credit: kriopd on GitHub
Credit: Radix Polymorphic Utility
Credit: Nashe Omirro
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:
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:
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:
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:
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:
Lastly, all of these solutions support passing in other components from potentially other libraries that also have forwarded refs in those components: