Appearance
How To Use forwardRef
With Generic Components
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>