React App Optimization: Mastering Memoization for Efficiency

Optimizing React applications is essential for performance, and mastering memoization techniques can significantly improve efficiency. This article explores the rendering process in React, the reasons behind re-rendering, and how to use memoization methods like useMemo, useCallback, and memo to optimize your components.

How does rendering work in React?

React is a component-driven framework that utilizes a top-down approach for rendering. When creating a new React app using Vite or a framework like Next.js, the first component to be rendered is typically the App component (or the _app file in Next.js). From there, React proceeds down the component tree, rendering each child of the parent component. This process continues until all components have been rendered.

The next question that comes to mind then is...

When does a component re-render?

The most common understanding is that "A component re-renders when it's props or state changes".

This is mostly correct. There is another important reason why a component re-renders: When its parent re-renders.

When a parent component re-renders, React's rendering mechanism ensures that the entire component tree within this parent component is also re-rendered. Let's examine an example to gain a better understanding.

Let us create two components:

  1. A parent component with a count state with a button that increments the count on click.

  2. A child component that randomly sorts an array of movies and displays them as a list.

// App.jsx

export default function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>+1</button>
      <Movies />
    </div>
  );
}
// Movies.tsx

const MOVIES = [
  "Harry Potter and the Goblet of Fire",
  "Titanic",
  "Avatar",
  "Terminator",
  "Star Wars",
  "The Lord of the Rings: The Return of the King"
];

const randomizeMovies = () => {
  const newMovies = [...MOVIES];
  for (let i = newMovies.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [newMovies[i], newMovies[j]] = [newMovies[j], newMovies[i]];
  }
  return newMovies;
};

const Movies = () => {
  return (
    <ul>
      {randomizeMovies().map((movie) => (
        <li key={movie}>{movie}</li>
      ))}
    </ul>
  );
};

export default Movies;

Let's run this and see what happens.

Try out yourself

The Movies component does not have any state or props being passed down, yet it still re-renders every time the count changes.

Why should we memoize in React?

We have a small array with only strings, so randomizing it during each render doesn't cause noticeable performance problems. However, if we had a large array with thousands of complex elements, it could lead to issues.

You might think this isn't a common situation, as usually large datasets are sorted on the backend and we just display them on the frontend. But imagine rendering a row with tags, inputs, buttons, and more for each movie. This could cause performance issues if you're showing 20-30 of them on a page.

How to optimize your components in React?

Now that we've seen how unnecessary renders might creep into our application, let us now look at how we can fix this with memoization.

What is memoization?

Wikipedia defines memoization as:

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.

What this means is that we call the expensive function only when the inputs to the function change.

In React, memoization can be accomplished using three methods:

  • useMemo hook

  • useCallback hook

  • memo function

Let's look at each of them in detail.

useMemo hook

useMemo is a hook provided by React that accepts two arguments:

  1. The first argument is a function that usually returns a value that is stored in a variable

  2. A dependency array similar to what's used in useEffect

Using this hook, we can make sure that the function passed as the first argument is only run when something specified in the dependency array changes.

Similar to the dependency array used in useEffect, if we pass an empty array, useMemo hook runs only once.

Let us now see how we can fix the problem we introduced in the first section using the useMemo hook. Let us modify the Movies component like so:

const Movies = () => {
  const movies = useMemo(randomizeMovies, []);

  return (
    <ul>
      {movies.map((movie) => (
        <li key={movie}>{movie}</li>
      ))}
    </ul>
  );
};

Try out yourself

Let us look at the result:

As you can see, randomization occurs only once when the component first renders. Hence, the Movies component renders only once.

useCallback hook

To understand the usage of this hook, we first need to understand the difference between primitive and reference data types.

Primitive vs Reference Data Types

Simple types store values directly in memory and the variable name is linked to the value. When these values are used in a function, they are copied. So, changes inside the function don't impact the original variable.

Examples of primitive data types in JavaScript are:

  • Numbers

  • String

  • Boolean

  • Null

  • Undefined

Imagine it as putting something inside a box and labelling it.

const a = 50;

Reference data types work differently. When you make one, the value goes into a place called heap memory, and the variable name points to that value.

const a = [1, 2, 3, 4]

When a reference type is passed to a function as an argument, only the pointer is copied into the function parameter. Therefore, any changes made to the parameter data within the function are also reflected in the variable outside the function.

Examples of reference data types in JavaScript are:

  • Objects

  • Functions

  • Arrays

Now, let's get back to the useCallback hook.

The useCallback hook - useMemo for functions

The useCallback hook is like useMemo, but for functions. While useMemo remembers values returned from a function, useCallback remembers the whole function.

Why should we memoize functions?

As we observed, functions are reference data types. Therefore, when a function (defined within the parent component) is passed as a prop to a child component, only a pointer to that function is passed along. Consequently, even if the child component is memoized, each time the parent re-renders, the function is recreated, the pointer to the function changes, and as a result, the child component is re-rendered.

Let's look at an example.

Let's take our previous example and imagine that the randomize function is defined in the App component and is passed as a prop to the Movies component.

// App.jsx

const MOVIES = [
  "Harry Potter and the Goblet of Fire",
  "Titanic",
  "Avatar",
  "Terminator",
  "Star Wars",
  "The Lord of the Rings: The Return of the King"
];

export default function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  const randomizeMovies = () => {
    const newMovies = [...MOVIES];
    for (let i = newMovies.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [newMovies[i], newMovies[j]] = [newMovies[j], newMovies[i]];
    }
    return newMovies;
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>+1</button>
      <Movies randomizeMovies={randomizeMovies} />
    </div>
  );
}
const Movies = ({ randomizeMovies }) => {
  const movies = useMemo(() => randomizeMovies(), [randomizeMovies]);

  return (
    <ul>
      {movies.map((movie) => (
        <li key={movie}>{movie}</li>
      ))}
    </ul>
  );
};

export default Movies;

Try out yourself

Let's look at the result.

As you can see, even though we are memoizing the result of the randomize function, the Movies component re-renders every time the parent App renders. This is because the randomizeMovies function changes with each render, and thus, the props of the Movies component also changes. This causes useMemo to run, and as a result, we see a different random order of movies every time App re-renders.

We can fix this problem by using the useCallback hook. Let's update the randomizeMovies function like so:

const randomizeMovies = useCallback(() => {
  const newMovies = [...MOVIES];
  for (let i = newMovies.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [newMovies[i], newMovies[j]] = [newMovies[j], newMovies[i]];
  }
  return newMovies;
}, []);

Try out yourself

Let's look at the result now:

Now, it is the same as when we used useMemo hook directly. The randomizeMovies function is now only created when the component first renders since the dependency array in the useCallback hook is empty.

Real-life example

A common real-life example of useCallback is in a search feature where we need to slow down or limit API calls so that the API request doesn't happen with every keypress.You can read more about it here: Debouncing in React.

Now let us look at another memoization technique in React - the memo function.

memo function

The memo function can be used to memoize a whole component by passing the component as an argument and using the memoized component instead of using the component directly.

const ExampleComponent = ({ text }) => {
    return (
        <p>{text}</p>
    )
}

export default React.memo(ExampleComponent)

What does the memo function do?

The memo function ensures that the component only re-renders when the props change. Thus, the ExampleComponent above only re-renders when text changes.

Now, you must be thinking, why do we need useMemo and useCallback when we can just wrap every component in memo?

There are two things to consider as an answer:

  1. It is NOT a good idea to wrap every component in your app in memo. We will discuss more about this in the "When should we memoize in React?" section below.

  2. Consider our example in useCallback above. If we did not wrap randomizeMovies in useCallback and wrapped Movies in memo, do you think Movies will not re-render every time count changes?

    Try out yourself

As you can see, Movies renders every time count changes. Why?

As mentioned earlier, memo ensures that Movies only renders when the props change and here, randomizeMovies changes every time App re-renders. Thus, the props of the Movies component change every time count changes and hence, memo re-renders the Movies component.

Now that we have taken a look at all three techniques in React used for the memoization of components, let us now see when to use what.

When should we memoize in React?

Let me tell you the golden rule for deciding when to memoize in React:

Use it only as a last resort

I understand, I understand! After learning so much about memoization and all its aspects, I am now advising you not to use it unless there is no other option.

The reason is very simple. If you go back and read the definition of memoization once more, you would notice that memoization requires caching of results based on inputs.

Storing the result in the cache also takes some time, although very little. If you memoize everything everywhere, this caching time will compound and your first render is affected - it becomes slow.

Thus, use memoization carefully. More often than not, there are better ways to solve a problem. Let us take our example problem above and show how it can be solved without using memoization at all.

All we need to do is separate the count logic into a separate component.

// App.jsx

export default function App() {
  return (
    <div className="App">
      <Count />
      <Movies />
    </div>
  );
}
// Count.jsx

const Count = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <>
      <p>Count: {count}</p>
      <button onClick={handleClick}>+1</button>
    </>
  );
};

export default Count;
const MOVIES = [
  "Harry Potter and the Goblet of Fire",
  "Titanic",
  "Avatar",
  "Terminator",
  "Star Wars",
  "The Lord of the Rings: The Return of the King"
];

const randomizeMovies = () => {
  const newMovies = [...MOVIES];
  for (let i = newMovies.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [newMovies[i], newMovies[j]] = [newMovies[j], newMovies[i]];
  }
  return newMovies;
};

const Movies = () => {
  return (
    <ul>
      {randomizeMovies().map((movie) => (
        <li key={movie}>{movie}</li>
      ))}
    </ul>
  );
};

export default Movies;

Try it yourself

Here is the result:

As you can see, just by making components wisely and storing the UI state as close to the component as possible, we can optimize our React components without even needing to use memoization.

Here are some more great articles regarding the same and I suggest you read them along with this article:

Honorary mention: useRef hook

Many examples of memoization online - especially that of functions - use the useRef hook. The useRef hook works similarly to useMemo. It makes sure that the value does not change between renders, although no dependency array can be supplied to useRef array.

But, this is not the main use case of useRef hook. useRef is usually used to store references (hence the name) to an actual DOM element.

Conclusion

In conclusion, optimizing React applications is crucial for performance, and mastering memoization techniques can significantly enhance efficiency. By understanding the rendering process in React and using memoization methods like useMemo, useCallback, and memo, you can prevent unnecessary re-renders and boost your app's performance. However, it's essential to use memoization wisely and explore other optimization techniques, such as better component design and local state management, before resorting to memoization.