Skip Main Navigation
Ben IlegboduBen Ilegbodu

Forwarding refs for a polymorphic React component in TypeScript

How to properly type polymorphic React components using forwardRef in TypeScript

Sunday, August 15, 2021 Β· 5 min read

Late last year I wrote about how to develop Polymorphic React Components in TypeScript. Polymorphic components are one of the React component patterns that enable us to create reusable and extendable components without having to rewrite display/layout, visual look-and-feel, and/or UI logic.

The implementation from that post, however, didn't discuss how to properly type a polymorphic component in TypeScript when it supports a ref using forwardRef(). That was an additional wrinkle that my team needed and figured out later. So let's talk about it now.

Just so we're all on the same page, here's our polymorphic Text component in TypeScript (including the helper types):

import React from 'react'

// Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts
// A more precise version of just React.ComponentPropsWithoutRef on its own
export type PropsOf<
  C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>
> = JSX.LibraryManagedAttributes<C, React.ComponentPropsWithoutRef<C>>

type AsProp<C extends React.ElementType> = {
  /**
   * An override of the default HTML tag.
   * Can also be another React component.
   */
  as?: C
}

/**
 * Allows for extending a set of props (`ExtendedProps`) by an overriding set of props
 * (`OverrideProps`), ensuring that any duplicates are overridden by the overriding
 * set of props.
 */
export type ExtendableProps<
  ExtendedProps = {},
  OverrideProps = {}
> = OverrideProps & Omit<ExtendedProps, keyof OverrideProps>

/**
 * Allows for inheriting the props from the specified element type so that
 * props like children, className & style work, as well as element-specific
 * attributes like aria roles. The component (`C`) must be passed in.
 */
export type InheritableElementProps<
  C extends React.ElementType,
  Props = {}
> = ExtendableProps<PropsOf<C>, Props>

/**
 * A more sophisticated version of `InheritableElementProps` where
 * the passed in `as` prop will determine which props can be included
 */
export type PolymorphicComponentProps<
  C extends React.ElementType,
  Props = {}
> = InheritableElementProps<C, Props & AsProp<C>>

// πŸ‘‡πŸΎπŸ‘‡πŸΎ sample usage in `Text` component πŸ‘‡πŸΎπŸ‘‡πŸΎ

interface Props {
  children: React.ReactNode
  color?: Color
  font?: 'thin' | 'regular' | 'heavy'
  size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}

type TextProps<C extends React.ElementType> = PolymorphicComponentProps<
  C,
  Props
>

export const Text = <C extends React.ElementType = 'span'>({
  as,
  children,
  font = 'regular',
  size = '4',
  color = 'gray-40',
  ...other
}: TextProps<C>) => {
  const classes = getClasses({ font, size, color })
  const Component = as || 'span'

  return (
    <Component {...other} className={classes}>
      {children}
    </Component>
  )
}

If you're not quite sure how all of this works, read Polymorphic React Components in TypeScript first for a step-by-step explanation.

When using forwardRef() generic function in TypeScript with a non polymorphic component, we pass in the type of the ref with the function call. Let's pretend our Text component is a simple component that always renders a <span>, so the type of the ref is HTMLSpanElement.

export const Text = React.forwardRef<HTMLSpanElement, TextProps>(
  ({ children, font = 'regular', size = '4', color = 'gray-40' }, ref) => {
    const classes = getClasses({ font, size, color })

    return (
      <span ref={ref} className={classes}>
        {children}
      </span>
    )
  },
)

When defining a polymorphic component, we need to replace HTMLSpanElement with our C generic type, but we can't. The problem is that forwardRef() is a function call so there's no opportunity to define the generic C type to pass to it. We could wrap forwardRef() in a function call, a component generator of sorts. But then we would no longer have the component interface and couldn't use JSX directly.

So here's what we came up with.

First we defined a new helper type, PolymorphicRef, that returns the type of the ref for the polymorphic component.

export type PolymorphicRef<
  C extends React.ElementType
> = React.ComponentPropsWithRef<C>['ref']

We can then use this type in our call to forwardRef() to avoid passing in the types to the function call. Instead we declare the types of the individual function parameters.

// highlight-range{10-11,17}
export const Text = React.forwardRef(
  <C extends React.ElementType = 'span'>(
    {
      as,
      children,
      font = 'regular',
      size = '4',
      color = 'blue',
      ...other
    }: TextProps<C>,
    ref?: PolymorphicRef<C>,
  ) => {
    const classes = `${color} ${font} ${size}`
    const Component = as || 'span'

    return (
      <Component {...other} className={classes} ref={ref}>
        {children}
      </Component>
    )
  },
)

So this works in that everything within the component code is strongly typed. But the type of Text itself is any 😭. So after trying and trying and trying, we gave in and explicitly defined the type for Text (now including a ref) using a type annotation.

type TextComponent = <C extends React.ElementType = 'span'>(
  props: TextProps<C>,
) => React.ReactElement | null

export const Text: TextComponent = React.forwardRef(
  <C extends React.ElementType = 'span'>(
    {
      as,
      children,
      font = 'regular',
      size = '4',
      color = 'blue',
      ...other
    }: TextProps<C>,
    ref?: PolymorphicRef<C>,
  ) => {
    const classes = `${color} ${font} ${size}`
    const Component = as || 'span'

    return (
      <Component {...other} className={classes} ref={ref}>
        {children}
      </Component>
    )
  },
)

TextComponent (naming is hard lol) is a function that takes in TextProps and returns JSX. In other words, it's a component. This almost gets us there, except now Text, from a type perspective, doesn't support a ref prop even though from a code perspective it does. πŸ€¦πŸΎβ€β™‚οΈ So the final step was to update TextProps to now support the PolymorphicRef.

export type PolymorphicComponentPropsWithRef<
  C extends React.ElementType,
  Props = {}
> = PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> }

interface Props {
  children: React.ReactNode
  color?: 'red' | 'green' | 'blue'
  font?: 'thin' | 'regular' | 'heavy'
  size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}

type TextProps<C extends React.ElementType> = PolymorphicComponentPropsWithRef<
  C,
  Props
>

type TextComponent = <C extends React.ElementType = 'span'>(
  props: TextProps<C>,
) => React.ReactElement | null

This introduces a new helper type called PolymorphicComponentPropsWithRef which adds the PolymorphicRef to the Props so that we can create TextProps. We could've made the base PolymorphicComponentProps support refs by default (see the PropsOf helper). But we were incrementally supporting refs in components, so we needed the separation.

So altogether the new Text component with all its reusable helper types looks like (new code highlighted):

import React from 'react'

// Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts
// A more precise version of just React.ComponentPropsWithoutRef on its own
export type PropsOf<
  C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>
> = JSX.LibraryManagedAttributes<C, React.ComponentPropsWithoutRef<C>>

type AsProp<C extends React.ElementType> = {
  /**
   * An override of the default HTML tag.
   * Can also be another React component.
   */
  as?: C
}

/**
 * Allows for extending a set of props (`ExtendedProps`) by an overriding set of props
 * (`OverrideProps`), ensuring that any duplicates are overridden by the overriding
 * set of props.
 */
export type ExtendableProps<
  ExtendedProps = {},
  OverrideProps = {}
> = OverrideProps & Omit<ExtendedProps, keyof OverrideProps>

/**
 * Allows for inheriting the props from the specified element type so that
 * props like children, className & style work, as well as element-specific
 * attributes like aria roles. The component (`C`) must be passed in.
 */
export type InheritableElementProps<
  C extends React.ElementType,
  Props = {}
> = ExtendableProps<PropsOf<C>, Props>

/**
 * A more sophisticated version of `InheritableElementProps` where
 * the passed in `as` prop will determine which props can be included
 */
export type PolymorphicComponentProps<
  C extends React.ElementType,
  Props = {}
> = InheritableElementProps<C, Props & AsProp<C>>

/**
 * Utility type to extract the `ref` prop from a polymorphic component
 */
export type PolymorphicRef<
  C extends React.ElementType
> = React.ComponentPropsWithRef<C>['ref']

/**
 * A wrapper of `PolymorphicComponentProps` that also includes the `ref`
 * prop for the polymorphic component
 */
export type PolymorphicComponentPropsWithRef<
  C extends React.ElementType,
  Props = {}
> = PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> }

// πŸ‘‡πŸΎπŸ‘‡πŸΎ sample usage in `Text` component πŸ‘‡πŸΎπŸ‘‡πŸΎ

interface Props {
  children: React.ReactNode
  color?: Color
  font?: 'thin' | 'regular' | 'heavy'
  size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}

type TextProps<C extends React.ElementType> = PolymorphicComponentPropsWithRef<
  C,
  Props
>

type TextComponent = <C extends React.ElementType = 'span'>(
  props: TextProps<C>,
) => React.ReactElement | null

export const Text: TextComponent = React.forwardRef(
  <C extends React.ElementType = 'span'>(
    {
      as,
      children,
      font = 'regular',
      size = '4',
      color = 'blue',
      ...other
    }: TextProps<C>,
    ref?: PolymorphicRef<C>,
  ) => {
    const classes = `${color} ${font} ${size}`
    const Component = as || 'span'

    return (
      <Component {...other} className={classes} ref={ref}>
        {children}
      </Component>
    )
  },
)

Now when we render a <Text> component, if the as prop is "label", not only does <Text> support the ref prop, but its type has to be HTMLLabelElement (or the generic HTMLElement). Type checking will fail when passing other types. πŸŽ‰


The types are still abstracted well enough that the definition of the Text component itself isn't overly complex. I just really wished we could avoid the explicit type annotation. I'm hoping this can be improved if/when the React team builds forwardRef() directly into function components.

If you have found a way around it or have found another solution for forwarding refs for polymorphic React components in TypeScript, I'd love to hear it! Feel free to reach out to me on Twitter at @benmvp.

Keep learning my friends. πŸ€“

Subscribe to the Newsletter

Get notified about new blog posts, minishops & other goodies

​
​

Hi, I'm Ben Ilegbodu. πŸ‘‹πŸΎ

I'm a Christian, husband, and father of 3, with 15+ years of professional experience developing user interfaces for the Web. I'm a Google Developer Expert Frontend Architect at Stitch Fix, and frontend development teacher. I love helping developers level up their frontend skills.

Discuss on Twitter // Edit on GitHub