10

I would like to use React's memo for a function that has a generic argument. Unfortunately the generic argument defaults to the generic and all the fancy generic deduction logic is lost (TypeScript v3.5.2). In the example below WithMemo (using React.memo) fails with:

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

while the WithoutMemo works just as expected.

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}`} />;

I've looked at this question, but I don't think it relates to my problem.

Possible solution

I've found a possible solution using generic interfaces but it seems a little crude:

const myArgStr = 'a string';
const myArgNo: number = 2;
const WithoutMemo = (
  <>
    <Test arg={myArgStr} output={o => `${o}: ${o.length}`} />
    <Test arg={myArgNo} output={o => `${o * 2}`} />
  </>
);

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

const MemoTest: MemoHelperFn = React.memo(Test);
const WithMemo = (
  <>
    <MemoTest arg={myArgStr} output={o => `${o}: ${o.length}`} />
    <MemoTest arg={myArgNo} output={o => `${o * 2}`} />
  </>
);

// Below fail as expected
const FailsWithoutMemo = (
  <>
    <Test arg={myArgNo} output={o => `${o}: ${o.length}`} />
    <Test arg={myArgStr} output={o => `${o * 2}`} />
  </>
);

const FailsWithMemo = (
  <>
    <MemoTest arg={myArgNo} output={o => `${o}: ${o.length}`} />
    <MemoTest arg={myArgStr} output={o => `${o * 2}`} />
  </>
);

Is there a more elegant idea of how to fix this?

Max Gordon
  • 5,367
  • 2
  • 44
  • 70
  • Well, number doesn't have length property, so compiler is right. Your code without generic works, because it's not called at all or called only with string, not with number. To fix it you need to add check for type and call length only when string is passed. – Radosław Cybulski Jul 04 '19 at 15:54
  • @RadosławCybulski - I don't agree. The point of the generic is to make sure that the compiler can match the `arg` with the `output` type. Adding a `typeof arg === 'string'` completely removes the elegance in the generic type. The example is a simplification of what I have in my [typeahead](https://github.com/gforge/react-typeahead-ts) package. There you can have a ton of dependencies that are decided on the generic, doing checks for input in each function would be just too painful. – Max Gordon Jul 04 '19 at 16:08
  • 1
    Look at your code `const WithMemo = `${o}: ${o.length}`} />;`. This code in `output` requires `o` to has `length` property, compiler tells you that number doesn't. Compiler won't guess, that you'll always pass strings to this `Test` instance (otherwise the lambda won't work). Whole idea of generics is not to make coding easier by typing less lines, but by making coding easier by catching errors (like the one you've made) earlier. You typed code, that works for string, but not for number and you try to push it to work with number, which compiler detects. – Radosław Cybulski Jul 05 '19 at 07:03
  • @RadosławCybulski - I guess this is an opinionated question. What I want is to figure out if I can have the generic functionality after calling the `React.memo`. Otherwise I could just skip the entire generic logic and just go with a custom `type MyType = string | number` and then check everywhere as you suggest. Again, this question is not about whether it is a good idea but *if it is possible*. – Max Gordon Jul 05 '19 at 07:23
  • `output: (o: any) => string;` in `TProps` definition should work then. You might also try restricting type on the lambda itself (`output={(o: string) => "${o}: ${o.length}"}` and `output={(o: number) => "${o * 2}"}` for example). – Radosław Cybulski Jul 05 '19 at 08:05
  • But that is a worse solution than what I have already proposed... Using `any` should be a last resort. – Max Gordon Jul 05 '19 at 10:22
  • True, but this is all I can think of to make it work. You might try to check react source code and see, what is going on there. – Radosław Cybulski Jul 05 '19 at 11:22
  • Replace your `Test` implementation with `import { SFC } from 'react'; const Test: SFC> | SFC> = props => { const { arg, output } = props; return
    {output(arg)}
    ; };`
    – kimamula Jul 12 '19 at 13:06
  • I don't know how to type `memo` properly, but what about using `React.PureComponent` instead? It gives you the benefit of not re-rendering unless props change and you can use generics with it https://pastebin.com/raw/LdewmRJK – Tomas Jul 12 '19 at 22:16
  • @Tomas - thanks for your suggestions but what I want is to retain the logic of the generic and it bothers me that a popular language like TypeScript doesn't seem to support higher order functions with generics. Regarding solutions, the *Possible solution* I wrote about works but it is annoying to have to resort to such hacks. – Max Gordon Jul 13 '19 at 07:37
  • @kimamula - see my answer to Tomas above (SO doesn't allow notifications to >1 user) – Max Gordon Jul 13 '19 at 07:37
  • @MaxGordon I think you want your component to accept only `TProps` or `TProps` as props and not `TProps`, and therefore `SFC> | SFC>` would be a better type declaration than `(arg: TProps): JSX.Element`. – kimamula Jul 13 '19 at 09:53
  • @MaxGordon have you read the code I posted in pastebin? It's clean, retains full generics support and the code is as brief as it gets. If `React.memo` is your real use case, and not just an example for another problem, I see no reason not to go with `React.PureComponent`. Generics support for higher-order functions has been implemented in TypeScrpt recently, but I'm not sure if that would work with React components https://github.com/microsoft/TypeScript/pull/30215 – Tomas Jul 13 '19 at 17:36
  • @Tomas - thanks. This example has no reason for PureComponent but I'm using hooks in the new version of my package and therefore I'm not interested in using PureComponent – Max Gordon Jul 13 '19 at 19:55
  • @kimamula - possibly but it kind of defeats using generics if I need to use unions. This example is oversimplified for a better SO experience, the real code feels much more natural with generics. – Max Gordon Jul 13 '19 at 19:57

4 Answers4

6

From https://stackoverflow.com/a/60170425/1747471

    interface ISomeComponentWithGenericsProps<T> { value: T; } 

    function SomeComponentWithGenerics<T>(props: ISomeComponentWithGenericsProps<T>) {
      return <span>{props.value}</span>;
    }

    export default React.memo(SomeComponentWithGenerics) as typeof SomeComponentWithGenerics;

Rafael Fontes
  • 1,195
  • 11
  • 19
1

To elaborate on the answer above, you can create your own memoisation hook with shallow comparison. It will still avoid unecessary render of your component (and any children). It's a little bit more verbose, but that's the best workaround I've found so far.

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
}

Then your code becomes

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

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

  return <div>{output(arg)}</div>
}

const MemoTest = <T extends string | number>(props: TProps<T>) =>
  useMemoRender(props, Test)
Thomas Roch
  • 1,208
  • 8
  • 19
  • As a small critique, if you want to write your own diff function (which I actually do in certain situations), try and keep the function fast/snappy. You're creating uneccesary objects and using an array enumerable (which are much slower than loops). When performance matters, which is the whole point of memoization, I think it's ok to be a little verbose ;). – Andrew Aug 29 '19 at 22:28
1

One option is to write your own HOC that includes a generic and integrates React.memo.

function Memoized<T>(Wrapped) {
    const component: React.FC<T> = props => <Wrapped {...props} />
    return React.memo(component)
}

Syntax might be a little off, but you get the idea

Andrew
  • 7,201
  • 5
  • 25
  • 34
0

As a workaround, we can use useMemo inside a component. It should be good enough.

Daniel Steigerwald
  • 1,075
  • 1
  • 9
  • 19