All Articles

How To Use forwardRef With Generic Components

Matt Pocock
Matt PocockMatt is a well-regarded TypeScript expert known for his ability to demystify complex TypeScript concepts.

The way React's forwardRef is implemented in TypeScript has some annoying limitations. The biggest is that it disables inference on generic components.

What Is A Generic Component?

A common use case for a generic component is a Table:

const Table = <T,>(props: {
  data: T[];
  renderRow: (row: T) => React.ReactNode;
}) => {
  return (
    <table>
      <tbody>
        {props.data.map((item, index) => (
          <props.renderRow key={index} {...item} />
        ))}
      </tbody>
    </table>
  );
};

Here, when we pass in an array of something to data, it will then infer that type in the argument passed to the renderRow function.

<Table
  // 1. Data is a string here...
  data={["a", "b"]}
  // 2. So ends up inferring as a string in renderRow.
  renderRow={(row) => {
(parameter) row: string
return <tr>{row}</tr>; }} />; <Table // 3. Data is a number here... data={[1, 2]} // 4. So ends up inferring as a number in renderRow. renderRow={(row) => {
(parameter) row: number
return <tr>{row}</tr>; }} />;

This is really helpful, because it means that without any extra annotations, we can get type inference on the renderRow function.

The Problem With forwardRef

The issue comes in when we try to add a ref to our Table component:

const Table = <T,>(
  props: {
    data: T[];
    renderRow: (row: T) => React.ReactNode;
  },
  ref: React.ForwardedRef<HTMLTableElement>
) => {
  return (
    <table ref={ref}>
      <tbody>
        {props.data.map((item, index) => (
          <props.renderRow key={index} {...item} />
        ))}
      </tbody>
    </table>
  );
};

const ForwardReffedTable = React.forwardRef(Table);

This all looks fine so far, but when we use our ForwardReffedTable component, the inference we saw before no longer works.

<ForwardReffedTable
  // 1. Data is a string here...
  data={["a", "b"]}
  // 2. But ends up being inferred as unknown.
  renderRow={(row) => {
(parameter) row: unknown
return <tr />; }} />; <ForwardReffedTable // 3. Data is a number here... data={[1, 2]} // 4. But still ends up being inferred as unknown. renderRow={(row) => {
(parameter) row: unknown
return <tr />; }} />;

This is extremely frustrating. But, it can be fixed.

The Solution

We can redefine forwardRef using a different type definition, and it'll start working.

Here's the new definition:

function fixedForwardRef<T, P = {}>(
  render: (props: P, ref: React.Ref<T>) => React.ReactNode
): (props: P & React.RefAttributes<T>) => React.ReactNode {
  return React.forwardRef(render) as any;
}

We can change our definition to use fixedForwardRef:

const ForwardReffedTable = fixedForwardRef(Table);

Suddenly, it just starts working:

<ForwardReffedTable
  data={["a", "b"]}
  renderRow={(row) => {
(parameter) row: string
return <tr />; }} />; <ForwardReffedTable data={[1, 2]} renderRow={(row) => {
(parameter) row: number
return <tr />; }} />;

This is my recommended solution - redefine forwardRef to a new function with a different type that actually works.

Matt's signature

Share this article with your friends

`any` Considered Harmful, Except For These Cases

Discover when it's appropriate to use TypeScript's any type despite its risks. Learn about legitimate cases where any is necessary.

Matt Pocock
Matt Pocock

No, TypeScript Types Don't Exist At Runtime

Learn why TypeScript's types don't exist at runtime. Discover how TypeScript compiles down to JavaScript and how it differs from other strongly-typed languages.

Matt Pocock
Matt Pocock