Beware of Arrays with useMemo

November 18th, 2021

There is a RoamJS component called PageInput, which you could find as part of the roamjs-components library. It's a standard input that autocompletes suggestions based on a predefined list of strings. I had a client discover that it was lagging so terribly in their app, that it was downright unusable.

At first, I thought that this doesn't make sense. Sure the computation that gathers the predefined lists of strings is expensive, but I was memoizing it so that it only ran once. Each keypress should be instantaneous.

Or so I thought...

What Is useMemo?

useMemo is one of the lesser-known React hooks that come with the standard library. The best use case for it is to calculate something expensive only once so that that operation doesn't run on every rerender. This is crucial for input components where the user is typing several characters per second. Take a look at the following example, which is a simplified version of my previously mentioned PageInput component:

const PageInput = () => {
  const suggestions = useMemo(
    () => getAllSuggestions(), 
    []
  );
  const [value, setValue] = useState('');
  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.change)}
      />
      <ul>
        {suggestions.map(s => <li key={s}>{s}</li>)}
      </ul>
    </div>
  );
}

In the example above, we are listing out all the suggestions under the input. We are also controlling the state with value and setValue, which means every time the user keypresses, we are re-rendering the component.

The useMemo statement on line 2 ensures that getAllSuggestions, which is a very expensive operation, only runs once. This is because the second argument to useMemo is a dependency array. It says "any time something in this array changes, rerun the function in the first argument". Since there is nothing in that dependency array, it will never rerun.

Let's tweak the example to include a prop:

const PageInput = ({ extra = 'home page' }) => {
  const suggestions = useMemo(
    () => getAllSuggestions().concat(extra), 
    [extra]
  );
  const [value, setValue] = useState('');
  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.change)}
      />
      <ul>
        {suggestions.map(s => <li key={s}>{s}</li>)}
      </ul>
    </div>
  );
}

Now, the component getAllSuggestions reruns every time the variable extra changes. In general, it's considered good practice to put every variable used in the callback function in your dependency array. Thankfully, user key presses will not change the value of extra, so the function won't rerun during a rerender. All good so far!

Finally, let's consider the case when the prop is an array:

const PageInput = ({ extras = [] }) => {
  const suggestions = useMemo(
    () => getAllSuggestions().concat(...extras), 
    [extras]
  );
  const [value, setValue] = useState('');
  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.change)}
      />
      <ul>
        {suggestions.map(s => <li key={s}>{s}</li>)}
      </ul>
    </div>
  );
}

Just like in the previous example, user keypresses shouldn't be changing the value of extras. But when I looked at my performance monitor, getAllSuggestions was running on every rerender! What's going on?!

The key is looking at the default value assignment. If I use this component elsewhere in my graph without specifying a prop, React will assign the prop a default value based on what I specified in the signature. On every rerender, this will be a new instance of that value. In the second example, when the default value was a string, useMemo will compare each item in its old dependency array with each item in its new dependency array and return true:

console.log('home page' === 'home page');
// true

But in the latest example, we are comparing arrays. Each array instantiation is its own object and equality doesn't hold in javascript. Therefore,

console.log([] === []);
// false

Because the default value assignment was assigning the prop a new array on every rerender, useMemo thought that its dependencies were changing on every rerender, firing our expensive getAllSuggestions callback every time! This was the root of the performance bottleneck that my client discovered in the PageInput component.

The Fix

We simply have to make sure that useMemo doesn't think our prop is changing on every render when there are no input props. This means ensuring that our default value assignment is constant:

const DEFAULT_EXTRAS = [];
const PageInput = ({ extras = DEFAULT_EXTRAS }) => {
  const suggestions = useMemo(
    () => getAllSuggestions().concat(...extras), 
    [extras]
  );
  const [value, setValue] = useState('');
  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.change)}
      />
      <ul>
        {suggestions.map(s => <li key={s}>{s}</li>)}
      </ul>
    </div>
  );
}

Now React will use the same predefined value to assign to our prop by default. This restores our behavior of useMemo to only firing our callback once and not on every user keypress. An alternative solution here includes always requiring the user to pass in a value for extras, but be careful! The same array equality bug could pass in through a higher level of abstraction from consumers of the component.

useMemo could be a powerful hook for improving the performance of your React components. Beware of how you use the dependency array however, as it could make the difference between a usable and unusable input.