reactjstypescriptgenerics

React with Typescript -- Generics while using React.forwardRef


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!


Solution

  • 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.