Kristóf Poduszló

Fixing generics in React

5 min read Updated

While plain components support generic props out of the box, wrappers like ‘forwardRef’, ‘lazy’ and ‘memo’ are cumbersome in that regard.

Let’s suppose we’re building a custom <select>. It should accept a value of any kind, dealing with serialization under the hood:

interface SelectProps<T> {
	value: T;
	/* … */
}

function Select<T>({ value, ...props }: SelectProps<T>) { return <select value={stringify(value)} {...props} />; }

(The implementation is a rough draft on purpose.)

Proceeding further, we may want to expose the underlying DOM node via forwardRef, e.g. for focus management:

import { forwardRef } from "react";

const Select = forwardRef(function Select<T>( { value, ...props }: SelectProps<T>, ref: React.ForwardedRef<HTMLSelectElement>, ) { return <select value={stringify(value)} {...props} />; });

In this case, however, T is always inferred to be unknown.

The outer Select has lost type information, so neither of the following works as expected:

<Select value={42} /> // Treated as `unknown`
<Select<number> value={42} /> // Invalid type argument

How could we mitigate the issue?

Delving into the type definitions for React, we find ourselves with tons of declarations. Seeking through its code, we stumble upon:

function forwardRef<T, P = {}>(
	render: ForwardRefRenderFunction<T, P>,
): ForwardRefExoticComponent<
	PropsWithoutRef<P> & RefAttributes<T>
>;

There’s so much going on here. Let’s break it down step-by-step:

  1. One of the arguments, namely render, takes a function. Therefore, forwardRef is a higher-order function.
  2. The return type is a component definition, as hinted by the name ForwardRefExoticComponent.
  3. The ref type is always inferred from the DOM node type T. If the props type P has a ref key, that’s ignored.

Since TypeScript 3.4, higher-order type inference enables retaining type parameters of generic function signatures.

Types highlighted in the previous snippet accept functions:

interface ForwardRefRenderFunction<T, P = {}> {
	(props: P, ref: ForwardedRef<T>): ReactNode;
	displayName?: string | undefined;
	defaultProps?: never | undefined;
	propTypes?: never | undefined;
}

interface ForwardRefExoticComponent<P> { defaultProps?: Partial<P> | undefined; propTypes?: WeakValidationMap<P> | undefined;
/* Inherited from `NamedExoticComponent<P>` */ displayName?: string | undefined;
/* Inherited from `ExoticComponent<P>` */ (props: P): ReactNode; readonly $$typeof: symbol; }

Apparently, type argument inference works only on function types having a single non-generic call signature and no other members.

Neither of the declarations meets those criteria. They’re function types with other members.

We could get rid of such bits, though, leaving us with:

interface ForwardRefRenderFunction<T, P = {}> {
	(props: P, ref: ForwardedRef<T>): ReactNode;
}

interface ForwardRefExoticComponent<P> { /* Inherited from `ExoticComponent<P>` */ (props: P): ReactNode; }

And that does the trick

With these changes in place, forwardRef would handle type parameters correctly.

On the other hand, we got rid of some public properties (all except $$typeof) inadvertently. Luckily, each of them has a substitute:

Also, notice that the resulting interfaces may be specified inline by rewriting them as function type literals:

type ForwardRefRenderFunction<T, P = {}> = (
	props: P,
	ref: ForwardedRef<T>,
) => ReturnType<FunctionComponent>;

type ForwardRefExoticComponent<P> = ( props: P, ) => ReturnType<FunctionComponent>;

ReturnType<FunctionComponent> is now used over ReactNode for compatibility with @types/react@<18.2.8 and typescript@<5.1. Otherwise, those versions would need ReactElement | null.

Applying the fix in practice

Patching the @types/react package sounds fairly complex. Fortunately, we have more robust techniques to choose from.

Within apps

Working on a standalone application, module augmentation can be leveraged to patch the affected types.

Just add the following to a .d.ts file and we’re set:

// react-patch.d.ts
import "react"; // For merging with existing types

declare module "react" { function forwardRef<T, P = NonNullable<unknown>>( render: ( props: P, ref: ForwardedRef<T>, ) => ReturnType<FunctionComponent>, ): ( props: PropsWithoutRef<P> & RefAttributes<T>, ) => ReturnType<FunctionComponent>;
function memo<P extends object>( Component: (props: P) => ReturnType<FunctionComponent>, propsAreEqual?: ( prevProps: Readonly<P>, nextProps: Readonly<P>, ) => boolean, ): (props: P) => ReturnType<FunctionComponent>; }

While the original function signatures remain intact, our overloads take priority for being more specific.

Within libraries

Packages can’t augment app-level type declarations implicitly unless their name starts with @types/. We can redeclare values with our own type assertions, though:

import { forwardRef, memo } from "react";

export const forwardRefWithGenerics = forwardRef as < T, P = NonNullable<unknown>, >( render: ( props: P, ref: React.ForwardedRef<T>, ) => ReturnType<React.FunctionComponent>, ) => ( props: React.PropsWithoutRef<P> & React.RefAttributes<T>, ) => ReturnType<React.FunctionComponent>;
export const memoWithGenerics = memo as <P extends object>( Component: (props: P) => ReturnType<React.FunctionComponent>, propsAreEqual?: ( prevProps: Readonly<P>, nextProps: Readonly<P>, ) => boolean, ) => (props: P) => ReturnType<React.FunctionComponent>;

When using generics, call those instead of React’s built-ins, like:

const Select = forwardRefWithGenerics(function Select<T>(
	{ value, ...props }: SelectProps<T>,
	ref: React.ForwardedRef<HTMLSelectElement>,
) {
	return <select value={stringify(value)} {...props} />;
});

What about lazy components?

Unfortunately, lazy can’t retain its generics by nature. It takes a Promise or another thenable as its parameter. Both must have a then member, violating the type argument inference criteria mentioned earlier.

The future

Honestly, I’d be happy if this post became obsolete.

Just as it is with styling deviations across browsers, we should strive to resolve the root cause of issues.

Thinking that way, I see the following prospects:

  • A library like TS Reset could simplify adopting fixes within apps.
  • If React would remove static properties like displayName, defaultProps and propTypes, the official typings could be refactored to support type argument inference.
  • forwardRef may become deprecated.
  • Eventually, memo may be made redundant by the React Forget compiler, alongside useMemo and useCallback.
  • Last, but not least, TypeScript’s inference logic might improve.

Wrapping up

Closing with a quote from the React Core team member Dan Abramov:

[We] often need to add before we can remove. Stepping stones.

My key takeaway is not to be afraid of experimenting. Ultimately, that’s how our world evolves.