101

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!

Primm
  • 1,438
  • 3
  • 12
  • 16

3 Answers3

156

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.

Ali Akbar Azizi
  • 3,272
  • 3
  • 25
  • 44
ford04
  • 66,267
  • 20
  • 199
  • 171
  • 3
    Note: There is a 4th alternative: augment react types of `forwardRef` to use generic functions (similar [example](https://stackoverflow.com/a/60389122/5669456) with `React.memo`) – ford04 Feb 25 '20 at 10:24
  • 1
    Thank you very much ford04. Your answer helped me a lot. – natterstefan Sep 05 '20 at 15:51
  • @ford04 Thanks a lot for your solution! Very clean explanation. For future reference, I created a small [code sandbox](https://codesandbox.io/s/mui-custom-button-typescript-example-rdtrz?file=/src/App.tsx:1987-2470) so people can see your solutions in action. – rickstaa Apr 05 '21 at 10:24
  • @ford04 I also tried to implement your 4th solution based on the example given [in this issue](https://stackoverflow.com/questions/60386614/how-to-use-props-with-generics-with-react-memo/60389122#60389122), but I was not able to find the right syntax. Did you during your work also find the right syntax for overloading the React.ForwardRef function such that it can take in a generic function? Thanks a lot in advance! – rickstaa Apr 06 '21 at 08:12
  • 1
    @arcety I just tried that on a local environment - seems to work so far. Take a look [here](https://tsplay.dev/Nl0yRN) 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) => ReactElement | null` instead of `ForwardRefExoticComponent`. A function type with properties in addition to a [single call signature](https://github.com/Microsoft/TypeScript/pull/30215) did infer type parameters to `unknown`, I guess that's how its implemented. Cheers – ford04 Apr 07 '21 at 17:05
  • @ford04 Wow thanks a lot for your answer! It works perfectly inside my [sandbox](https://codesandbox.io/s/mui-custom-button-typescript-example-rdtrz?file=/src/App.tsx:5986-6003). I was using the ForwardRefExoticComponent type when I tried it myself. Thanks a lot for explaining why that does not work. – rickstaa Apr 08 '21 at 19:51
  • 1
    @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. – ford04 Apr 09 '21 at 08:50
  • @ford04 Wow, thanks a lot! Your answer now gives me a clear understanding of what is going on. – rickstaa Apr 09 '21 at 09:25
  • Hi @ford04,I went with the fourth solution (augment TS type) and I'm not able to do `MyComponent.displayName = "MyComponent"` anymore. Do you have a solution for me please ? – Donovan BENFOUZARI Dec 24 '21 at 10:21
  • 1
    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". – danwoz May 10 '22 at 01:37
  • @ford04 I followed your steps, but getting a small error. Can you please help? https://ibb.co/HPzsKhX – arunmmanoharan Sep 16 '22 at 14:58
  • I was fighting component prop typings for days until I realized it was the forwardRef gobbling them up. This comment saved my sanity, really appreciated. – Lucas P. Dec 01 '22 at 15:54
  • This answer is fantastic but in the end I stumbled upon a writeup of this answer here https://fettblog.eu/typescript-react-generic-forward-refs which made the `type augmentation` example more clear to me trying to properly wrap the new Next 13.4 next/link component forwarding props etc – Can Rau May 24 '23 at 00:22
14

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:

smac89
  • 39,374
  • 15
  • 132
  • 179
  • Can confirm this works! I think it's more elegant as we don't augment forwardRef that can change in the future. – Yulian Jan 26 '23 at 13:19
  • 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 – PaulCo Aug 30 '23 at 08:31
1

import React, { forwardRef, ForwardedRef } from 'react';
import {
  DeepPartial,
  FieldValues,
  FormProvider,
  SubmitHandler,
  useForm,
  Resolver,
} from 'react-hook-form';

import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

type GenericOnSubmit<T extends FieldValues> = (
  data: T,
  event?: React.BaseSyntheticEvent
) => void;

type FormProps<T extends FieldValues> = {
  onSubmit: SubmitHandler<T>;
  defaultValues?: DeepPartial<T>;
  children: React.ReactNode;
  schema?: Yup.ObjectSchema<Yup.AnyObject, Yup.AnyObject, Yup.AnyObject, ''>;
  ref?: ForwardedRef<HTMLFormElement>;
};

interface WithForwardRefType<T extends FieldValues>
  extends React.FC<FormProps<T>> {
  <T extends FieldValues>(props: FormProps<T>): ReturnType<
    React.FC<FormProps<T>>
  >;
}

export const Form: WithForwardRefType<FieldValues> = forwardRef<
  HTMLFormElement,
  FormProps<FieldValues>
>(
  <T extends FieldValues>(
    { onSubmit, children, defaultValues, schema }: FormProps<T>,
    refForm: ForwardedRef<HTMLFormElement>
  ): JSX.Element => {
    console.log(refForm);

    const methods = useForm<T>({
      defaultValues,
      ...(schema ? { resolver: yupResolver(schema) as Resolver<T> } : {}),
    });

    const handleSubmit = methods.handleSubmit;

    return (
      <FormProvider {...methods}>
        <form
          ref={refForm}
          onSubmit={handleSubmit(onSubmit as GenericOnSubmit<T>)}
        >
          {children}
        </form>
      </FormProvider>
    );
  }
);

This code is a TypeScript React component that provides a generic Form component with forwardRef support. Here's a breakdown of the code:

  • The GenericOnSubmit type represents a function that handles form submission and takes the form data and an optional event as parameters.
  • The FormProps interface defines the props for the Form component, including onSubmit (the form submission handler), defaultValues (optional initial values for form fields), children (the components rendered inside the form), schema (optional validation schema using Yup), and ref (optional forwarded ref for the form element).
  • The WithForwardRefType interface extends React.FC<FormProps<T>> to define a generic functional component type that supports forwardRef. It includes a call signature that allows using the component as a function call.
  • The Form component is defined using forwardRef. It takes a generic parameter T extends FieldValues to specify the type of form field values. It receives the FormProps as its props, along with the forwarded ref. Inside the component, it initializes the useForm hook with the provided options, including default values and schema if available. It also logs the ref to the console. The handleSubmit function is extracted from the methods object returned by useForm. The component renders a FormProvider component to provide the form methods and wraps the form element with the form tag. The onSubmit function is called with the correct type using a type assertion, and the children are rendered inside the form.
  • Finally, the Form component is exported with the WithForwardRefType to provide the generic type and forwardRef support.

This code allows you to create a generic Form component that can be used with different types of form field values and supports forwarding the ref to the form element. It also integrates with react-hook-form and yup for form validation.

Jonatas Rodrigues
  • 57
  • 1
  • 2
  • 12