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.