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:

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

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) => {
(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:

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

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) => {
(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:

tsx
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:

tsx
const ForwardReffedTable = fixedForwardRef(Table);

Suddenly, it just starts working:

tsx
<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

Deriving vs Decoupling: When NOT To Be A TypeScript Wizard

In this book teaser, we discuss deriving vs decoupling your types: when building relationships between your types or segregating them makes sense.

Matt Pocock
Matt Pocock

NoInfer: TypeScript 5.4's New Utility Type

Learn how TypeScript's new utility type, NoInfer, can improve inference behavior by controlling where types are inferred in generic functions.

Matt Pocock
Matt Pocock