Definitelytyped: [@type/react] Generic Props lost with React memo

Created on 23 Jul 2019  路  22Comments  路  Source: DefinitelyTyped/DefinitelyTyped

For some reason React.memo drops the generic prop type and creates a regular union type. I've posted on StackOverflow but there doesn't seem to be a non-hacky solution (despite the 100 point bounty I put on the question). The following toy example illustrates the issue:

interface TProps<T extends string | number> {
  arg: T;
  output: (o: T) => string;
}

const Test = <T extends string | number>(props: TProps<T>) => {
  const { arg, output } = props;
  return <div>{output(arg)} </div>;
};

const myArg = 'a string';
const WithoutMemo = <Test arg={myArg} output={o => `${o}: ${o.length}`} />;

const MemoTest = React.memo(Test);
const WithMemo = <MemoTest arg={myArg} output={o => `${o}: ${o.length}`} />;

The last line results in an error message for the output function's o.length:

Property 'length' does not exist on type 'string | number'.
  Property 'length' does not exist on type 'number'.

A workaround is to assign the React.memo to a generic interface:

interface MemoHelperFn {
  <T extends string | number>(arg: TProps<T>): JSX.Element;
}

but this in turn requires forcing TypeScript by adding a @ts-ignore to the assignment.

  • [x] I tried using the @types/react package version 16.8.23.
  • [x] I tried using the latest stable version of tsc: 3.5.3
  • Authors: @johnnyreilly @bbenezech @digiguru @DovydasNavickas @eps1lon @ericanderson @DovydasNavickas @ferdaber @franklixuefei @guilhermehubner @hotell @Jessidhia @jrakotoharisoa @lukyth @onigoetz @pascaloliv @pzavolinsky @saranshkataria @theruther4d @tkrotoff @pshrmn @threepointone

Most helpful comment

Unless you need all the baggage, maybe just override the typings locally:

const typedMemo: <T>(c: T) => T = React.memo;
const MemoTest = typedMemo(Test);
const WithMemo = <MemoTest arg={myArg} output={o => `${o}: ${o.length}`} />;

All 22 comments

TS doesn't currently support the ability to "forward" free type parameters when performing operations with higher order functions, so the free type parameter is lost when you create it, so yeah unfortunately we're out of luck.

I kind of suspected that although it seems like it should be a high-priority issue as this should affect any higher-order-compoent. Is this the correct TS issue?

As a workaround, we can use useMemo inside a component.

@steida Thanks. Saw your reply on SO as well. I've upvoted it but left it unchecked as it would be nice to have a link to the correct TS-issue so that people can vote on the issue and perhaps get some traction around the issue. There are currently 3000+ TS issues and I guess it isn't obvious for the TS team in what order to try and resolve these.

I've left an answer on the SO question as well. Like @steida said, it is possible to use useMemo, so I posted an example of it with props shallow comparison. It still adds extra verbosity but it's the best workaround I've found. It gives you the same properties than memo: your components and its children are not unnecessarily re-rendered thanks to shallow comparison.

One problem it doesn't solve is the use of forwardRef.

Posting it here as well:

import { ReactElement, useRef } from 'react'

const shallowEqual = <Props extends object>(left: Props, right: Props) => {
  if (left === right) {
    return true
  }

  const leftKeys = Object.keys(left)
  const rightKeys = Object.keys(right)

  if (leftKeys.length !== rightKeys.length) {
    return false
  }

  return leftKeys.every(key => (left as any)[key] === (right as any)[key])
}

export const useMemoRender = <Props extends object>(
  props: Props,
  render: (props: Props) => ReactElement,
): ReactElement => {
  const propsRef = useRef<Props>()
  const elementRef = useRef<ReactElement>()

  if (!propsRef.current || !shallowEqual(propsRef.current, props)) {
    elementRef.current = render(props)
  }

  propsRef.current = props

  return elementRef.current as ReactElement
}

I'm using hook for that too, it's a little bit simpler than @troch solution

export function useMemoComponent<P>(
  component: FunctionComponent<P>,
) {
  return useMemo(() => memo(component), [component]);
}

and than inside some render function I use

const MemoizedComponent = useMemoComponent(Component)

Unless you need all the baggage, maybe just override the typings locally:

const typedMemo: <T>(c: T) => T = React.memo;
const MemoTest = typedMemo(Test);
const WithMemo = <MemoTest arg={myArg} output={o => `${o}: ${o.length}`} />;

Unless you need all the baggage, maybe just override the typings locally:

const typedMemo: <T>(c: T) => T = React.memo;
const MemoTest = typedMemo(Test);
const WithMemo = <MemoTest arg={myArg} output={o => `${o}: ${o.length}`} />;

Out of curiosity is there documentation on that T = React.memo syntax?

It's not a syntax thing, perhaps this will clarify:

interface IdentityFunction {
  <T>(fn: T): T
}

const typedMemo: IdentityFunction = React.memo;
const typedMemo = React.memo as IdentityFunction; // same as above but using assertion

Oh thanks... I misread it.

Looks like that workaround works with forwardRef only if the assertion is used due to the mismatch of the input and return types on forwardRef compared to memo's signature.

Right, I doubt that annotating it will work since they're subtypes of each other.

I think what you're looking for is...

const MemoizedComponent = React.memo(innerComponent) as typeof innerComponent;
export function typedMemo<C extends ComponentType>(Component: C): C {
  return memo(Component) as C;
}

This one returns the same type of component you're passing to it, but makes it memorized (and keeps generics)

react.d.ts

```ts

import * as React, { memo } from "react"

declare module "react" {
function memo

(
Component: React.SFC

,
propsAreEqual?: (
prevProps: Readonly>,
nextProps: Readonly>
) => boolean
): React.SFC

type GetComponentProps = T extends
| React.ComponentType
| React.Component
? P
: never
}

Unless you need all the baggage, maybe just override the typings locally:

const typedMemo: <T>(c: T) => T = React.memo;
const MemoTest = typedMemo(Test);
const WithMemo = <MemoTest arg={myArg} output={o => `${o}: ${o.length}`} />;

This worked for me. I also added a second argument, like so:

const typedMemo: <T>(
  c: T,
  areEqual?: (
    prev: React.ComponentProps<T>,
    next: React.ComponentProps<T>
  ) => boolean
) => T = React.memo

const Memo = (Component, (prev, next) => prev.text === next.text)
<Memo text={'hi'} />

Seems to be working for me. The only problem is I get errors in the areEqual, saying T is not assignable to "view", so I'm not sure what it should have instead of React.ComponentProps<T>.

I ended up using this:

import { ComponentType, memo, useEffect, ComponentProps } from 'react';

type PropsComparator<C extends ComponentType> = (
  prevProps: Readonly<ComponentProps<C>>,
  nextProps: Readonly<ComponentProps<C>>,
) => boolean;

function typedMemo<C extends ComponentType<any>>(
  Component: C,
  propsComparator?: PropsComparator<C>,
) {
  return (memo(Component, propsComparator) as any) as C;
}

function Foo<D>(props: { foo: 2, bar: D }) {
  return <div>foo</div>;
}

const Memoed = typedMemo(Foo);

/**
 * Generic remained!
 * const Memoed: <D>(props: {
 *     foo: 2;
 *     bar: D;
 * }) => JSX.Element
 */

It could be more succinct by leveraging TS features Parameters:

Edit: thanks @jleider for pointing out the generic was not properly passed.

export function genericMemo<C extends ComponentType<any>>(
  Component: Parameters<typeof React.memo>[0],
  propsAreEqual?: Parameters<typeof React.memo>[1]
) {
  return (React.memo(Component, propsAreEqual) as unknown) as C;
}

then (example from post above)

function Foo<D>(props: { foo: 2, bar: D }) {
  return <div>foo</div>;
}

const Memoed = genericMemo(Foo);

or even:

interface IFooProps<T extends object> {
  model: T;
}

export const Foo = genericMemo(<T extends object>(props: IFooProps<T>) => {
  return <div>foo</div>;
});

Did they fix this?

I'm using "@types/react": "^16.9.52", "@types/react-dom": "^16.9.8", "typescript": "^4.1.0-beta"

function Foo<D>(props: { foo: 2, bar: D }) {
  return <div>foo</div>;
}

const Memoed2 = React.memo(Foo, (prev, next) => prev.foo === next.foo);

// const Memoed2: React.MemoExoticComponent<(<D>(props: {
//     foo: 2;
//     bar: D;
// }) => JSX.Element)>

I'm still having problem with versions that @benneq provided.
With

....
<Memoed2<SomeType>/>
....

I get:
Expected 0 type arguments, but got 1.

@pie6k's solution worked for me! https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-699521381 馃檹

@marc-ed-raffalli's (https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-704575310) exhibited the same issue I was having with the standard React typings likely due to the lack of generic type parameters in the function signature.

@nandorojo's solution worked by using T extends ComponentType<any> instead of T.

const typedMemo: <T extends ComponentType<any>>(
  c: T,
  areEqual?: (
    prev: React.ComponentProps<T>,
    next: React.ComponentProps<T>
  ) => boolean
) => T = React.memo;

And to make it work without variable assignment at runtime, I have modified it to work as follows:

declare module "react" {
  function memo<T extends React.ComponentType<any>>(
    c: T,
    areEqual?: (
      prev: Readonly<React.ComponentProps<T>>,
      next: Readonly<React.ComponentProps<T>>
    ) => boolean
  ): T;
}
Was this page helpful?
0 / 5 - 0 ratings