React custom hooks and useMemo hooks

furnaceX picture furnaceX · Jul 6, 2020 · Viewed 7.6k times · Source

I have two "expensive" functions that I would like to memoize in my react application, as they take a long time to render. The expensive functions are used in an array map. I would like to memoize each result of the array map, so that if one element of the array is changed, only that element's expensive functions will be recalculated. (And to memoize the expensive functions independently, as sometimes only one will need to be recalculated.) I'm struggling with how to memoize and pass in the current array value.

Here is the working demo, without memoization:

import React, { useMemo, useState } from "react";

const input = ["apple", "banana", "cherry", "durian", "elderberry", "apple"];

export default function App() {
  const output = input.map((msg, key) => createItem(msg, key));
  return <div className="App">{output}</div>;
}

const createItem = (message, key) => {   // expensive function 1
  console.log("in createItem:", key, message);

  return (
    <div key={key}>
      <strong>{"Message " + (1 + key)}: </strong>
      {message} =>{" "}
      <span
        id={"preview-" + key}
        dangerouslySetInnerHTML={{ __html: msgSub(message) }}
      />
    </div>
  );
};

const msgSub = message => {   // expensive function 2
  const messageSub = message.replace(/[a]/g, "A").replace(/[e]/g, "E");
  console.log("in msgSub:", message, messageSub);
  return messageSub;
};

(I don't have it functioning on SO's editor, so view and run it on codesandbox.)

Here is one of my attempts using custom hooks and useMemo hooks.

Any guidance would be greatly appreciated!

And bonus points for illustrating how to get react to work in SO's editor!

Answer

user2340824 picture user2340824 · Jul 6, 2020

My first step was to change createItem so that Item was its only Function Component. This meant I could then memoize the <Item/> component so that it would only render if the props changed, which importantly is message and key/index (as you were rendering the value previously).

Working example of this https://codesandbox.io/s/funny-chaplygin-pvfoj.

const Item = React.memo(({ message, index }) => {
  console.log("Rendering:", index, message);

  const calculatedMessage = msgSub(message);

  return (
    <div>
      <strong>{"Message " + (1 + index)}: </strong>
      {message} =>{" "}
      <span
        id={"preview-" + index}
        dangerouslySetInnerHTML={{ __html: calculatedMessage }}
      />
    </div>
  );
});

const msgSub = message => {
  // expensive function 2
  const messageSub = message.replace(/[a]/g, "A").replace(/[e]/g, "E");
  console.log("in msgSub:", message, messageSub);
  return messageSub;
};

You can see that at initial render, it renders all Items with their message but especially apple twice.

If two components are being rendered independent of each other, and happen to use the same props, these two components will be rendered. React.memo doesn't save component renders.

It must render the Item component twice <Item message="apple" />, once for apple at index 0 and again at index 5 apple.


You'll noticed I placed a button in the App, which when clicked, will change the contents of the array.

Changing the original array a little, I placed carrot at index 4 in the original array and moved it to index 2 in the new array, when I updated it.

  const [stringArray, setStringArray] = React.useState([
    "apple",
    "banana",
    "cherry",
    "durian",
    "carrot",
    "apple" // duplicate Apple
  ]);

  const changeArray = () =>
    setStringArray([
      "apple",
      "banana",
      "carrot",  // durian removed, carrot moved from index 4 to index 2
      "dates", // new item
      "elderberry", // new item
      "apple"
    ]);

If you look at the console, you'll see that on the first render you see in msgSub: carrot cArrot but when we update the array, in msgSub: carrot cArrot is called again. This is because the key on <Item /> so it's forced to rerender. This is correct, because our key is based on index, and carrot changed position. However, you said msgSub is an expensive function...


Local Memoization

I noticed in your array you had apple twice.

const input = ["apple", "banana", "cherry", "durian", "elderberry", "apple"];

I felt that you wanted to memoize the calculation so that apple wasn't calculated again, if apple was previously calculated.

We can store the calculated value in our own memoization state, so we can look up the message value, and see if we have calculated it previously.

const [localMemoization, setLocalMemoization] = useState({});

We want to make sure we update this localMemoization when stringArray changes.

  React.useEffect(() => {
    setLocalMemoization(prevState =>
      stringArray.reduce((store, value) => {
        const calculateValue =
          prevState[value] ?? store[value] ?? msgSub(value);
        return store[value] ? store : { ...store, [value]: calculateValue };
      })
    );
  }, [stringArray]);

This line const calculateValue = prevState[value] ?? store[value] ?? msgSub(value);

  • checks the previous state to see if it had the previous value (for the case of carrot moving the first array, to the second array).
  • checks the current store to see if it has the value (for the 2nd apple case).
  • finally, if nothing has seen it, we use the expensive function to calculate the value for the first time.

Using the same logic as the previous render, we now look up the calculated value and pass that into <Item> so that it's React.memo will prevent rerenders of that component if App was to render again.

const output = stringArray.map((msg, key) => {
    const expensiveMessage = localMemoization[msg];
    return (
      <Item
        key={key}
        message={msg}
        calculatedValue={expensiveMessage}
        index={key}
      />
    );
  });

Working example here https://codesandbox.io/s/attempt-with-custom-hooks-and-usememo-y3lm5?file=/src/App.js:759-1007

From this, in the console, we can see that on the first render, apple is only calculated once.

When the array changes, we don't calculate carrot again and only the changed items dates and elderberry.