Gideon Idoko

Memoization in React Done Right

March 20th, 2022   —  11 min read   —  Gideon Idoko
Blog Cover

Performance is a vital quality every software product ought to possess. It is a measure of how efficiently your software meets the response time requirements when a user interacts with it. There are a couple of ways in which React applications can be optimized for performance. In this article, we'll learn how to increase the performance of React applications using a technique called memoization and how to do it the right way.

Prerequisites

Before going through this article, having a little background knowledge of React will help.

What is Memoization?

In computing, memoization or memoisation is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again. ~ Wikipedia

Memoization is used to store the result of prior executed computations so that they can be reused later. This is the traditional memoization.

Traditional Memoization

To understand how traditional memoization works, firstly, let's take a look at a simple function that increments a number by one.

const increment = (num) => num + 1;

console.log(increment(1)) // 2
console.log(increment(1)) // 2
console.log(increment(2)) // 3

The increment function above gets recomputed upon every call even if we are incrementing the same number.

Let's look at a simple function that takes a function as an argument and memoizes (caches) the result of the computation of the function.

const memoize = (func) => {
    const cache = {}; // cache for computed results
    // return memoized function
    return (...args) => { // args is an array of all the argument that will be passed to the memoized function
        let n = args[0];  // get the first argument
        if (n in cache) {
            console.log('Fetching from cache');
            return cache[n]; // returns the cached result & terminates
        }
        console.log('Computing result');
        let result = func(n);
        cache[n] = result; // cache the result using the first argument as key
        return result;
    }
}

The memoize function has a cache object where it stores the first argument and result of the passed-in function computations as key-value pairs.

Now, let's wrap out the increment function with the memoize function to get a new memoized function.

const memoizedIncrement = memoize(increment);

console.log(memoizedIncrement(1)) // Computing result 2
console.log(memoizedIncrement(1)) // Fetching from cache  2
console.log(memoizedIncrement(2)) // Computing result  3
console.log(memoizedIncrement(2)) // Fetching from cache 3
console.log(memoizedIncrement(1)) // Fetching from cache 2

Our new memoized function, memoizedIncrement, is only recomputed if a new argument that is not in the cache as a key is passed. This, in turn, makes our code more efficient. Memoization isn't actually needed for a simple function as the one above but it's a lot helpful for functions with expensive computations where computation time is more critical than space.

NB: In memoization, we trade memory (to store our cache) for efficiency (to speed up our application).

Memoization in React

React provides the following for memoization:

  1. React.memo
  2. useMemo
  3. useCallback

1. React.memo

React.memo is a higher order component that is used to memoize React components. Memoization of React components is a bit different from traditional memoization. In traditional memoization, recomputation is only done if the passed argument is not in the cache, while in React component memoization, recomputation (rerender in this case) is done if the props changes. NB. props are like argument but for React components.

Let's rewrite our memoize to look like how memoization of React components works.

const memoize = (func) => {
    let cache = {}; 
    // return memoized function
    return (...args) => {
        let n = args[0];  // get the first argument
        if (n in cache) {
            console.log('Fetching from cache');
            return cache[n]; // returns the cached version & terminate
        }
        console.log('Computing result');
        let result = func(n);
        cache = { [n]: result }; // overwrite cache
        return result;
    }
}

const memoizedIncrement = memoize(increment);

console.log(memoizedIncrement(1)) // Computing result 2
console.log(memoizedIncrement(2)) // Computing result 3
console.log(memoizedIncrement(1)) // Computing result 2
console.log(memoizedIncrement(1)) // Fetching from cache 2
console.log(memoizedIncrement(2)) // Computing result 3

Here we can see that the result is only fetched from the cache if the argument stays the same otherwise the function is recomputed. Also, if the props passed to a component that is memoized with React.memo() remain the same, a rerender of that component is not triggered.

Let's look at a small React application that displays a number, two buttons to increment and decrement it and lastly a greeting text.

import { useState } from 'react';
import { render } from 'react-dom';

const Greeter = ({ name }: { name: string }) => {
  console.log('Greeter is rendered');
  return <div>Hello { name }</div>
};

const App = () => {
  const [num, setNum] = useState(0);
  const name = 'John Doe';
  return (
    <div style={{ textAlign: 'center', fontFamily: 'monospace' }}>
      <h1>Memoization App</h1>
      <h1>{ num }</h1>
      <button style={btn} onClick={() => setNum(num + 1)}>Increment</button>
      <button style={btn} onClick={() => setNum(num - 1)}>Decrement</button>
      <Greeter name={name} />
    </div>
  );
}

const btn = {
  border: 'none',
  margin: '0.5rem',
  backgroundColor: 'rgb(6, 90, 90)',
  padding: '0.5rem 1rem',
  borderRadius: '6px',
  color: 'white',
  fontFamily: 'inherit'
}

render(<App />, document.getElementById('root'));

Here is what our app looks like:

Memoization app

The Greeter component is passed a name prop that is from our app's state and rendered. Ideally, the Greeter component is supposed to render just once i.e. when the App is rendered but that's not the case here. If we click on our increment button five times, the number is incremented from 0 to 5 also, the Greeter component is rerendered 5 times. The image below shows the log from the Greeter component which is printed 5 times.

memoappconsole

Basically, the Greeter component will be initially rendered and rerendered every time the state of the App component changes irrespective of if the props passed to Greeter stays the same or not. This is where React.memo comes in. Let's wrap the Greeter component with the memo HOC (higher order component).

import { memo } from 'react';

const Greeter = memo(({ name }) => {
  console.log('Greeter is rendered');
  return <div>Hello { name }</div>
});

Now, as long as the name prop of the Greeter component remains the same, the component will only render once (initial render). Hurray, we now have for ourselves, an optimized app👏. But wait, there is a caveat, shallow comparison.

React.memo does a shallow comparison and so, it only compares prop values with primitive data types like numbers, string, booleans etc and not prop values with referential integrity like objects, array, functions and so on. This is because values with referential integrity have different references every time they are used. No two non-primitive values are exactly the same unless they have the same reference.

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const obj1 = { one: 1 };
const obj2 = { one: 1 };
const func1 = () => 1;
const func2 = () => 1;
const arr3 = arr1;
 
console.log(arr1 === arr2); // false
console.log(obj1 === obj2); // false
console.log(func1 === func2); // false
console.log(arr1 === arr3); // true

So, every time we pass in a non-primitive value as props, no memoization will be done on that component even if it is wrapped with React.memo.

Let's rewrite our Greeter component to take in a non-primitive object value as name props.

const Greeter = memo(({ name }) => {
  console.log('Greeter is rendered');
  return <div>Hello { name.first + ' ' + name.last }</div>
});

const App = () => {
  const [num, setNum] = useState(0);
  const name = { first: 'John', last: 'Doe' };
  return (
    <div style={{ textAlign: 'center', fontFamily: 'monospace' }}>
      <h1>Memoization App</h1>
      <h1>{ num }</h1>
      <button style={btn} onClick={() => setNum(num + 1)}>Increment</button>
      <button style={btn} onClick={() => setNum(num - 1)}>Decrement</button>
      <Greeter name={name} />
    </div>
  );
}

The Greeter will be called every time the state of the App component changes. How do we fix this problem?😢 How can we memoize our component even when we want to pass a prop with an object value? Wear a smile, React.memo takes in a second argument which is a callback that enables us to take control over the comparison. This callback is passed the previous and next props as the first and second argument respectively and it should return a boolean. We can take advantage of this and use the values of the properties of the object props to tell if the component should be rerendered or not.

const Greeter = memo(({ name }) => {
  console.log('Greeter is rendered');
  return <div>Hello { name.first + ' ' + name.last }</div>
}, (prevProps, nextProps) => {
  return prevProps.name.first === nextProps.name.first && prevProps.name.last === nextProps.name.last; // true
});

Here, the Greeter component will be rerendered if the above callback evaluates to false which will only happen if the first & last properties of the name object change.

2. useMemo

useMemo is a React hook that memoizes the result of a computation and always returns the memoized result. useMemo takes in as an argument a function that returns the computation result and an array of dependencies Another computation is done only when any of the dependencies change.

In our App component, we could memoize the result of the name object. In that case, we wouldn't need the second argument of the React.memo HOC. Here's how:

import { useState, useMemo } from 'react';

const Greeter = memo(({ name }) => {
  console.log('Greeter is rendered');
  return <div>Hello { name.first + ' ' + name.last }</div>
});

const App = () => {
  const [num, setNum] = useState(0);
  const name = { first: 'John', last: 'Doe' };
  const memoizedName = useMemo(() => ({ first: name.first, last: name.last }), [name.first, name.last]); // will recompute only if name.first or name.last changes
  return (
    <div style={{ textAlign: 'center', fontFamily: 'monospace' }}>
      <h1>Memoization App</h1>
      <h1>{ num }</h1>
      <button style={btn} onClick={() => setNum(num + 1)}>Increment</button>
      <button style={btn} onClick={() => setNum(num - 1)}>Decrement</button>
      <Greeter name={memoizedName} />
    </div>
  );
}

Here, the Greeter component would only be rendered initially and rerendered if the first & last properties of the name object change just like when we used the second argument of React.memo.

3. useCallback

useCallback like useMemo is also a React hook but unlike useMemo, it memoizes a callback and always returns the memoized callback. It takes as an argument a function (callback) and an array of dependencies. A new version of the callback is only returned when any of the dependencies change.

Let's rewrite our app to alert a greeting text when the user clicks on the greeting text of the Greeter component.

const Greeter = memo(({ name, onClick }) => {
  console.log('Greeter is rendered');
  return <div onClick={onClick}>Hello { name.first + ' ' + name.last }</div>
});

const App = () => {
  const [num, setNum] = useState(0);
  const name = { first: 'John', last: 'Doe' };
  const memoizedName = useMemo(() => ({ first: name.first, last: name.last }), [name.first, name.last]); // will recompute only if name.first or name.last changes
  const handleClick = () => alert(`Hello ${name.first} ${name.last}`);
  return (
    <div style={{ textAlign: 'center', fontFamily: 'monospace' }}>
      <h1>Memoization App</h1>
      <h1>{ num }</h1>
      <button style={btn} onClick={() => setNum(num + 1)}>Increment</button>
      <button style={btn} onClick={() => setNum(num - 1)}>Decrement</button>
      <Greeter name={memoizedName} onClick={handleClick} />
    </div>
  );
}

The memoization is broken after adding the click event to our Greeter component making it rerender every time the state of App changes. This is because we are passing handleClick which is a function (a non-primitive value) as the value of our onClick props. Remember that React.memo only does a shallow comparison hence, the rerender. Here, we can use the useCallback hook to memoize the handleClick function as below:

import { useState, useMemo, useCallback } from 'react';

const App = () => {
  const [num, setNum] = useState(0);
  const name = { first: 'John', last: 'Doe' };
  const memoizedName = useMemo(() => ({ first: name.first, last: name.last }), [name.first, name.last]); // will recompute only if name.first or name.last changes
  const handleClick = useCallback(() => alert(`Hello ${name.first} ${name.last}`), [name.first, name.last]);
  return (
    <div style={{ textAlign: 'center', fontFamily: 'monospace' }}>
      <h1>Memoization App</h1>
      <h1>{ num }</h1>
      <button style={btn} onClick={() => setNum(num + 1)}>Increment</button>
      <button style={btn} onClick={() => setNum(num - 1)}>Decrement</button>
      <Greeter name={memoizedName} onClick={handleClick} />
    </div>
  );
}

We've successfully restored memoization to our Greeter component by using the useCallback hook.

Some things to note

Although memoization is a pretty fascinating technique and performance boost, do NOT use it everywhere in your application. Why? because anytime we memoize, our application takes up memory to cache results. We may end up hurting the performance of our application instead of improving it. What's kind of ideal? Memoize:

  • components or functions with expensive and time-consuming computations.
  • when computation time is more critical than space in your application.
  • components or functions that receive the same prop or argument values overtime.
  • when a function or component is more frequently computed or rendered.

Conclusion

We have seen how the performance of our application can be improved with memoization. We also discussed the 3 tools that React provides for memoization and their respective use cases. We also discussed how memoization in React slightly differs from the traditional one.

I hope the information in this article helps you create better and more performant applications in the future.

Thanks for reading😊.