173

I am trying to create a generic component where a user can pass the a custom OptionType to the component to get type checking all the way through. This component also required a React.forwardRef.

I can get it to work without a forwardRef. Any ideas? Code below:

WithoutForwardRef.tsx

export interface Option<OptionValueType = unknown> {
  value: OptionValueType;
  label: string;
}

interface WithoutForwardRefProps<OptionType> {
  onChange: (option: OptionType) => void;
  options: OptionType[];
}

export const WithoutForwardRef = <OptionType extends Option>(
  props: WithoutForwardRefProps<OptionType>,
) => {
  const { options, onChange } = props;
  return (
    <div>
      {options.map((opt) => {
        return (
          <div
            onClick={() => {
              onChange(opt);
            }}
          >
            {opt.label}
          </div>
        );
      })}
    </div>
  );
};

WithForwardRef.tsx

import { Option } from './WithoutForwardRef';

interface WithForwardRefProps<OptionType> {
  onChange: (option: OptionType) => void;
  options: OptionType[];
}

export const WithForwardRef = React.forwardRef(
  <OptionType extends Option>(
    props: WithForwardRefProps<OptionType>,
    ref?: React.Ref<HTMLDivElement>,
  ) => {
    const { options, onChange } = props;
    return (
      <div>
        {options.map((opt) => {
          return (
            <div
              onClick={() => {
                onChange(opt);
              }}
            >
              {opt.label}
            </div>
          );
        })}
      </div>
    );
  },
);

App.tsx

import { WithoutForwardRef, Option } from './WithoutForwardRef';
import { WithForwardRef } from './WithForwardRef';

interface CustomOption extends Option<number> {
  action: (value: number) => void;
}

const App: React.FC = () => {
  return (
    <div>
      <h3>Without Forward Ref</h3>
      <h4>Basic</h4>
      <WithoutForwardRef
        options={[{ value: 'test', label: 'Test' }, { value: 1, label: 'Test Two' }]}
        onChange={(option) => {
          // Does type inference on the type of value in the options
          console.log('BASIC', option);
        }}
      />
      <h4>Custom</h4>
      <WithoutForwardRef<CustomOption>
        options={[
          {
            value: 1,
            label: 'Test',
            action: (value) => {
              console.log('ACTION', value);
            },
          },
        ]}
        onChange={(option) => {
          // Intellisense works here
          option.action(option.value);
        }}
      />
      <h3>With Forward Ref</h3>
      <h4>Basic</h4>
      <WithForwardRef
        options={[{ value: 'test', label: 'Test' }, { value: 1, label: 'Test Two' }]}
        onChange={(option) => {
          // Does type inference on the type of value in the options
          console.log('BASIC', option);
        }}
      />
      <h4>Custom (WitForwardRef is not generic here)</h4>
      <WithForwardRef<CustomOption>
        options={[
          {
            value: 1,
            label: 'Test',
            action: (value) => {
              console.log('ACTION', value);
            },
          },
        ]}
        onChange={(option) => {
          // Intellisense SHOULD works here
          option.action(option.value);
        }}
      />
    </div>
  );
};

In the App.tsx, it says the WithForwardRef component is not generic. Is there a way to achieve this?

Example repo: https://github.com/jgodi/generics-with-forward-ref

Thanks!

7 Answers 7

235

Creating a generic component as output of React.forwardRef is not directly possible 1 (see bottom). There are some alternatives though - let's simplify your example a bit for illustration:

type Option<O = unknown> = { value: O; label: string; }
type Props<T extends Option<unknown>> = { options: T[] }

const options = [
  { value: 1, label: "la1", flag: true }, 
  { value: 2, label: "la2", flag: false }
]

Choose variants (1) or (2) for simplicity. (3) will replace forwardRef by usual props. With (4) you globally chance forwardRef type definitions once in the app.

Playground variants 1, 2, 3

Playground variant 4

1. Use type assertion ("cast")

// Given render function (input) for React.forwardRef
const FRefInputComp = <T extends Option>(p: Props<T>, ref: Ref<HTMLDivElement>) =>
  <div ref={ref}> {p.options.map(o => <p>{o.label}</p>)} </div>

// Cast the output
const FRefOutputComp1 = React.forwardRef(FRefInputComp) as
  <T extends Option>(p: Props<T> & { ref?: Ref<HTMLDivElement> }) => ReactElement

const Usage11 = () => <FRefOutputComp1 options={options} ref={myRef} />
// options has type { value: number; label: string; flag: boolean; }[] 
// , so we have made FRefOutputComp generic!

This works, as the return type of forwardRef in principle is a plain function. We just need a generic function type shape. You might add an extra type to make the assertion simpler:

type ForwardRefFn<R> = <P={}>(p: P & React.RefAttributes<R>) => ReactElement |null
// `RefAttributes` is built-in type with ref and key props defined
const Comp12 = React.forwardRef(FRefInputComp) as ForwardRefFn<HTMLDivElement>
const Usage12 = () => <Comp12 options={options} ref={myRef} />

2. Wrap forwarded component

const FRefOutputComp2 = React.forwardRef(FRefInputComp)
// ↳ T is instantiated with base constraint `Option<unknown>` from FRefInputComp

export const Wrapper = <T extends Option>({myRef, ...rest}: Props<T> & 
  {myRef: React.Ref<HTMLDivElement>}) => <FRefOutputComp2 {...rest} ref={myRef} />

const Usage2 = () => <Wrapper options={options} myRef={myRef} />

3. Omit forwardRef alltogether

Use a custom ref prop instead. This one is my favorite - simplest alternative, a legitimate way in React and doesn't need forwardRef.

const Comp3 = <T extends Option>(props: Props<T> & {myRef: Ref<HTMLDivElement>}) 
  => <div ref={myRef}> {props.options.map(o => <p>{o.label}</p>)} </div>
const Usage3 = () => <Comp3 options={options} myRef={myRef} />

4. Use global type augmentation

Add following code once in your app, perferrably in a separate module react-augment.d.ts:

import React from "react"

declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: ForwardedRef<T>) => ReactElement | null
  ): (props: P & RefAttributes<T>) => ReactElement | null
}

This will augment React module type declarations, overriding forwardRef with a new function overload type signature. Tradeoff: component properties like displayName now need a type assertion.


1 Why does the original case not work?

React.forwardRef has following type:

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

So this function takes a generic component-like render function ForwardRefRenderFunction, and returns the final component with type ForwardRefExoticComponent. These two are just function type declarations with additional properties displayName, defaultProps etc.

Now, there is a TypeScript 3.4 feature called higher order function type inference akin to Higher-Rank Types. It basically allows you to propagate free type parameters (generics from the input function) on to the outer, calling function - React.forwardRef here -, so the resulting function component is still generic.

But this feature can only work with plain function types, as Anders Hejlsberg explains in [1], [2]:

We only make higher order function type inferences when the source and target types are both pure function types, i.e. types with a single call signature and no other members.

Above solutions will make React.forwardRef work with generics again.

Sign up to request clarification or add additional context in comments.

16 Comments

Note: There is a 4th alternative: augment react types of forwardRef to use generic functions (similar example with React.memo)
@arcety I just tried that on a local environment - seems to work so far. Take a look here and paste that into local env as well (playground currently doesn't like TS + React at least for me). Also note the return type (props: P & RefAttributes<T>) => ReactElement | null instead of ForwardRefExoticComponent. A function type with properties in addition to a single call signature did infer type parameters to unknown, I guess that's how its implemented. Cheers
@arcety glad it works. This alternative should be fine, if you don't need types for additional properties like defaultProps etc. And you only need to do it once in the app. I updated answer with a bit better explanation.
For anyone using the global type augmentation solution (i.e. the fourth solution), I've been adding display name like so: (MyComponent as NamedExoticComponent).displayName = "MyComponent";. The NamedExoticComponent type is exported from "react".
Option 4 seems to work when added to a separate react.d.ts file. It didn't work when I added it to a "common" .d.ts file and "broke" react package. Also, Option 1 doesn't work and the parent component says that Property ref is not found in IntrinsicAttributes & SomeComponentProps<T>.
|
28

I discovered this question from reading this blog post, and I think there is a more straight-forward way of accomplishing this than the current accepted answer has proposed:

First we define an interface to hold the type of the component using something called a call signature in typescript:

interface WithForwardRefType extends React.FC<WithForwardRefProps<Option>>  {
  <T extends Option>(props: WithForwardRefProps<T>): ReturnType<React.FC<WithForwardRefProps<T>>>
}

Notice how the function signature itself is declared as generic, not the interface - this is the key to making this work. The interface also extends React.FC in order to expose some useful Component properties such as displayName, defaultProps, etc.

Next we just supply that interface as the type of our component, and without having to specify the type of the props, we can pass that component to forwardRef, and the rest is history...

export const WithForwardRef: WithForwardRefType = forwardRef((
  props,
  ref?: React.Ref<HTMLDivElement>,
) => {
  const { options, onChange } = props;
  return (
    <div ref={ref}>
      {options.map((opt) => {
        return (
          <div
            onClick={() => {
              onChange(opt);
            }}
          >
            {opt.label}
          </div>
        );
      })}
    </div>
  );
});

Sandbox link here


References:

5 Comments

Can confirm this works! I think it's more elegant as we don't augment forwardRef that can change in the future.
In my case I wasn't using an Option that T would extends but directly a custom type on my component props, so just replaced Option by unknown on the first occurrence and removed it from the extends
I think your example code may be missing a section: typescript interface WithForwardRefType extends React.FC<WithForwardRefProps<Option>> { <T extends Option>(props: WithForwardRefProps<T> & { ref: React.Ref<HTMLDivElement> }): ReturnType<React.FC<WithForwardRefProps<T>>> } The missing part is & { ref: React.Ref<HTMLDivElement> }
I don't see how this solution helps to export generic type, it just fixates to generic parameter to Option type
@blazkovicz it's generic in the sense that it allows you to pass any interface which matches Option, to the onChange function. This function can accept anything of type Option. In other words, it's a bounded generic type. We can't make it unbounded because we do rely on some properties of the opt object like opt.label.
22

The most understandable answer:

import { Ref, forwardRef, useImperativeHandle } from 'react'

export interface ComponentRef {
  foo: VoidFunction
}

export type ComponentProps<T> = {
  data: T[]
}

const Component = <T,>(props: ComponentProps<T>, ref: Ref<ComponentRef>) => {
  const { data } = props

  const foo = () => {
    console.log(data)
  }

  useImperativeHandle(ref, () => ({
    foo: foo,
  }))

  return <div>{data.length}</div>
}

export default forwardRef(Component) as <T>(
  props: ComponentProps<T> & { ref?: Ref<ComponentRef> },
) => ReturnType<typeof Component>

5 Comments

By far the most straightforward answer.
Some examples use ForwardedRef<ComponentRef> instead of Ref<ComponentRef>. What is the difference?
Ref<ComponentRef> is used directly in functional components to refer to DOM nodes or component instances. —————————ForwardedRef<ComponentRef> is used when you need to forward a ref from a parent to a child component using React.forwardRef. It allows the parent to access a child DOM node even when that child is wrapped by other components.
This bares more explanation. Why do you need useImperativeHandle()? where is VoidFunction defined?
0

Option 4 supports displayName if an interface is used for the return type of forwardRef.

import React from "react"

// Redecalare forwardRef
declare module "react" {
  function ComponentToRender<T, P>(props: P, ref: React.ForwardedRef<T>): React.ReactElement | null;
  ComponentToRender.displayName as string;

  function ForwardedComponent<T, P>(props: P & React.RefAttributes<T>): React.ReactElement | null;
  ForwardedComponent.displayName as string;

  function forwardRef<T, P = {}>(
    render: ComponentToRender<T, P>
  ): ForwardedComponent<T, P>;
}

Comments

0

Here is a easy way of doing it:

type PossibleValue = string | number | Record<string, unknown>;

type Props<TValue extends PossibleValue> = {
  value?: TValue | null;
};

function Selector<TValue extends PossibleValue>(
  { value, placeholder, onChange, options }: Props<TValue>,
  ref: ForwardedRef<View>,
) {    
  return (
    {...}
  );
}

export const Select = forwardRef(Selector);

2 Comments

Doesn't work: imgur.com/a/eVNcqW0
Probably something is off with your ts-config. imgur.com/a/7GIXlDj It does work
0

I was looking for such simple adjustable example (from accepted answer):

type PropsWithForwardedRef<TProps, TRefComponent> = TProps & { forwardRef: React.ForwardedRef<TRefComponent> };

interface RadioGroupProps<TValue> {
    options: {value: TValue; label: string}[];
    onChange: (value: TValue) => void;
    // ...
}

class RadioGroupWithForwardedRef<TValue> extends React.PureComponent<PropsWithForwardedRef<RadioGroupProps<TValue>, HTMLDivElement>> {
    render() {
        return (
            <div ref={this.props.forwardRef}>
                {/* render */}
            </div>
        );
    }
}

export const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps<unknown>>(
    ({ children, ...props }, ref) => <RadioGroupWithForwardedRef {...props} forwardRef={ref}>{children}</RadioGroupWithForwardedRef>
) as <TValue>(p: RadioGroupProps<TValue> & { ref?: React.Ref<HTMLDivElement> }) => React.ReactElement;

All I needed is for props to be consistent in type and checked against each other - if options are given with string values, we require onChange for string value etc.

Comments

0

I've had to address this for MUI's Box component which conflicts with react-three/fiber. Casting is the most self-contained way I've found to handle this, and still allows all of MUI's complicated component type overrides to pass through. And using the named function


import _MuiBox from "@mui/material/Box";
import { OverrideProps } from "@mui/material/OverridableComponent";
import { BoxTypeMap } from "@mui/system";
import { forwardRef } from "react";

export type MuiBoxProps<
  RootComponent extends React.ElementType = BoxTypeMap["defaultComponent"],
  AdditionalProps = {},
> = OverrideProps<BoxTypeMap<AdditionalProps, RootComponent>, RootComponent> & {
  component?: React.ElementType;
};

export const MuiBox = forwardRef(
  <RootComponent extends React.ElementType = BoxTypeMap["defaultComponent"], AdditionalProps = {}>(
    props: MuiBoxProps<RootComponent, AdditionalProps>,
    ref: React.Ref<unknown>,
  ) => {
    return (
      // PRAGMA: ts-ignore not normally required, but this hides the TS error
      // from react-three/fiber's Box clobbering MUI's.

      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      <_MuiBox {...props} ref={ref} />
    ) as React.ReactElement & { displayName?: string };
  },
);

MuiBox.displayName = "MuiBox";

Now you can do things like:

const muiImage = <MuiBox 
  component="img" 
  src="/myimg.jpg" 
  alt="A screengrab of a 1980s pop star" 
/>

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.