The way React's forwardRef
is implemented in TypeScript has some annoying limitations. The biggest is that it disables inference on generic components.
#
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) => { 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 really helpful, because it means that without any extra annotations, we can get type inference on the renderRow
function.
#
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) => { 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.
#
We can redefine forwardRef
using a different type definition, and it'll start working.
Here's the new definition:
import React from "react";
// ---cut---
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
:
import React from "react";
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;
}
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>
);
};
// ---cut---
const ForwardReffedTable = fixedForwardRef(Table);
Suddenly, it just starts working:
import React from "react";
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;
}
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 = fixedForwardRef(Table);
// ---cut---
<ForwardReffedTable
data={["a", "b"]}
renderRow={(row) => {
// ^?
return <tr />;
}}
/>;
<ForwardReffedTable
data={[1, 2]}
renderRow={(row) => {
// ^?
return <tr />;
}}
/>;
This is my recommended solution - redefine forwardRef
to a new function with a different type that actually works.