Skip to content

How To Use forwardRef With Generic Components

Chinese

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:

tsx
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>
  );
};
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.

tsx
<Table
  {/* 1. Data is a string here... */}
  data={["a", "b"]}
  {/* 2. So ends up inferring as a string in renderRow. */}
  renderRow={(row) => {
    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) => {
    return <tr>{row}</tr>;
  }}
/>;
<Table
  {/* 1. Data is a string here... */}
  data={["a", "b"]}
  {/* 2. So ends up inferring as a string in renderRow. */}
  renderRow={(row) => {
    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) => {
    return <tr>{row}</tr>;
  }}
/>;

This is very helpful, because it means that withour 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:

tsx
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);
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.

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

ts
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;
}
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:

ts
const ForwardReffedTable = fixedForwardRef(Table);
const ForwardReffedTable = fixedForwardRef(Table);

Suddenly, it just starts working:

tsx
<Table
  {/* 1. Data is a string here... */}
  data={["a", "b"]}
  {/* 2. So ends up inferring as a string in renderRow. */}
  renderRow={(row) => {
    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) => {
    return <tr>{row}</tr>;
  }}
/>;
<Table
  {/* 1. Data is a string here... */}
  data={["a", "b"]}
  {/* 2. So ends up inferring as a string in renderRow. */}
  renderRow={(row) => {
    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) => {
    return <tr>{row}</tr>;
  }}
/>;

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

Example of a Generic Component in Vue3

In Vue 3, handling type inference and the passing of references is different from React. Vue 3 introduces the Composition API as a new way to organize component logic, and its integration with TypeScript allows for more flexible handling of type inference and references. Here are examples of how to deal with similar issues in Vue 3.

Suppose we have a similar generic Table component, but in the context of Vue 3:

vue
<template>
  <table>
    <tbody>
      <tr v-for="(item, index) in data" :key="index">
        <slot :row="item"></slot>
      </tr>
    </tbody>
  </table>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

export default defineComponent({
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true,
    },
  },
});
</script>
<template>
  <table>
    <tbody>
      <tr v-for="(item, index) in data" :key="index">
        <slot :row="item"></slot>
      </tr>
    </tbody>
  </table>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

export default defineComponent({
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true,
    },
  },
});
</script>

In this component, we use a slot instead of the renderRow function from the React example, allowing the parent component to decide how to render each row. This approach retains the flexibility of type inference and is compatible with Vue's templating system.

In Vue 3, if you want to pass a reference (e.g., to a DOM element or another component instance), you might use ref along with the reactive API provide/inject.

vue
<script setup lang="ts">
import { ref, provide } from 'vue';

const tableRef = ref(null);
provide('tableRef', tableRef);
</script>
<script setup lang="ts">
import { ref, provide } from 'vue';

const tableRef = ref(null);
provide('tableRef', tableRef);
</script>

Then, where it's needed, inject the reference:

vue
<script setup lang="ts">
import { inject } from 'vue';

const tableRef = inject('tableRef');
</script>
<script setup lang="ts">
import { inject } from 'vue';

const tableRef = inject('tableRef');
</script>

Updated Date:

Light tomorrow with today.