Understanding React's `useEffect` Memory Leak and How to Avoid It by Building React HTTP Request Poller

October 21, 2024
Understanding React's `useEffect` Memory Leak and How to Avoid It by Building React HTTP Request Poller

Overview of useEffect

In the dynamic world of React, functional components combined with the powerful useEffect hook have become essential for managing side effects like data fetching. However, using useEffect isn't without its pitfalls—one common issue developers face is memory leaks, especially when building features like an HTTP request poller.

This article explores how memory leaks can sneak into your functional components through useEffect and, more importantly, how to prevent them. Whether you're just starting with React or looking to sharpen your skills, we'll break down the concepts in a clear and straightforward way to help you build efficient and reliable applications.

But first, let's dive into the signature of useEffect:

1useEffect(() => {
2  // do something here
3  // when dependency1 or dependency2 changed
4  return () => {
5    // cleanup code
6  };
7}, [dependency1, dependency2]);
tsx

In the nutshell: useEffect is a hook that receives an array of dependencies and a function that is invoked whenever one of the dependencies changes. And the cleanup code will run once the component is unmounted or before the effect runs again due to dependency change which we'll explore further through an exercise later.

Example

Let's start with a simple example to see useEffect in action:

1import { useState, useEffect } from "react";
2
3export function SomeComponent() {
4  const [message, setMessage] = useState("");
5  useEffect(() => {
6    console.log(`"message" variable value has changed, it is now: ${message}`);
7  }, [message]);
8
9  return (
10     return (
11    <div>
12      <p>Current Message: {message}</p>
13      <button
14        onClick={() => setMessage(`Random floating point : ${Math.random()}`)}
15      >
16        change message
17      </button>
18    </div>
19  );
20  );
21}
22
23// <SomeComponent />
tsx

Everytime we click the "change message" button it will invoke the function inside useEffect. This is how basically useEffect is used.

Run once? You Bet!

Sometimes, we just want to something once. Here's how developers often use the useEffect for that:

1import { useEffect } from "react";
2
3export function SomeComponent() {
4  useEffect(() => {
5    console.log(
6      "This runs once when the component is rendered for the first time"
7    );
8  }, []);
9  return <div>just a component</div>;
10}
11
12// <SomeComponent />
tsx

If we check on the browser console, you see the message in web browser console only once only after the first render. This works because we passing nothing to the dependencies array. so no dependencies, no repeat performance!

Great job mastering the basics of the useEffect hook and working through our initial demo! 🎉 Now, let’s take things a step further and address a common challenge many developers face: memory leaks.

Although memory leaks might seem to occur only in specific scenarios, they are actually quiet common, especially among developers who are new to the useEffect hook. HTTP requests, or heavy operations, such as processing data, can continue running even after the user's cancelled (unmounted) the operation or not properly cleaned up. This can lead in sluggish performance, high memory consumption, and wasting resources, particularly if the component is re-mounted or re-render frequently.

To show you how this works, we’ll build a simple HTTP Request Poller using useEffect. In this hands-on project, we'll see how memory leaks can sneak into your code and learn how to prevent them by managing tasks and cleanup correctly. By the end, you'll have the tools to keep your applications running smoothly and efficiently. Let’s dive in! 🚀

Deep dive into useEffect by building a HTTP Request Poller

Before we embark on the exercise, make sure you:

  1. Have Node.js 18+ installed. If not, I recommend installing it through nvm, a Node Version Manager.
  2. Be familiar setting up new React typescript project using vite.
  3. Understand basic TypeScript type or interface declaration.
  4. Familiar with React's useState hook.
  5. Understand how javascript Fetch API API work
  6. Be familiar with the Javascript setInterval function via window.setInterval.

The requirements:

  1. Keep it minimal. This is just to showcase useEffect and memory leak, no need for error handling or fancy loading indicators.
  2. A toggle button to start and stop fetching GitHub repositories.
  3. Have a list of the repositories and a live-updating it every 5 seconds.

The technical requirements:

  1. Use this GitHub endpoint to fetch the list of GitHub repositories:
    https://api.github.com/search/repositories?q=language:javascript&sort=updated&order=desc&per_page=10
  2. Prevent memory leak by stopping the fetching process when the component is unmounted (this will illustrate the cleanup part in useEffect)

Tech stack:

  1. Node.js 18+ installed.
  2. Use Vite to setup a new the React's project.

The setup

Assuming you have setup react typescript through Vite. Let's first create type definition for GitHub repositories search API response:

1/** * These interfaces represent the structure of the response * from GitHub's search repositories API endpoint. */
2
3export interface SearchRepositoriesResponse {
4  total_count: number;
5  incomplete_results: boolean;
6  items: Repository[];
7}
8
9export interface Repository {
10  id: number;
11  name: string;
12  full_name: string;
13  owner: Owner;
14  html_url: string;
15}
16
17interface Owner {
18  id: number;
19  node_id: string;
20  url: string;
21}
typescript

Note: this is a slimmed-down type definition. The real one is much more detailed, but we're keeping it simple for our purposes
GitHub search API client
1import type { SearchRepositoriesResponse } from "./types";
2
3export async function fetchGithubRepositories(): Promise<SearchRepositoriesResponse> {
4  try {
5    const response = await fetch(
6      "https://api.github.com/search/repositories?q=language:javascript&sort=updated&order=desc&per_page=10"
7    );
8
9    const data = await response.json();
10    if (!response.ok) {
11      const msg = `Failed to fetch repositories: ${ data.message || "Unknown error" }`;
12      throw new Error(msg);
13    }
14    return data;
15  } catch (error) {
16    console.error("Error fetching GitHub repositories:", error);
17    throw error;
18  }
19}
typescript
The Main Page
1import { useState } from "react";
2import { TopRepository } from "./top-repository";
3
4function App() {
5  const [showRepositoryPage, setShowRepositoryPage] = useState(false);
6
7  return (
8    <div>
9      <p>This is root component</p>
10      <button onClick={() => setShowRepositoryPage((prev) => !prev)}>
11        {showRepositoryPage ? "Hide" : "Show"} Top Repository Page
12      </button>
13      <hr />
14      {showRepositoryPage && <TopRepository />}
15    </div>
16  );
17}
18
19export default App;
tsx

This main page is basically showing the Top Repository component when showRepositoryPage is true. You might wonder why would we not just work on the src/App.tsx? Great question! Separating TopRepository is essential for demonstrating the memory leak scenario later, now please just hang on with me.

Notes: at this point top-respository.tsx is not exists yet. Let's create it!
The main component
1import { useEffect, useState, useRef } from "react";
2import type { Repository } from "./types";
3import { fetchGithubRepositories } from "./api-client";
4
5export function TopRepository() {
6  const intervalRef = useRef<number | null>(null);
7  const [startFetching, setStartFetching] = useState(false);
8  const [repositories, setRepositories] = useState<Repository[]>([]);
9
10  useEffect(() => {
11    const fetchData = async () => {
12      console.log("Fetching Github Repositories...");
13      setRepositories((await fetchGithubRepositories()).items);
14      console.log("Fetching Github Repositories done!");
15    };
16
17    if (startFetching) {
18      intervalRef.current = setInterval(fetchData, 5000);
19    } else {
20      if (intervalRef.current) {
21        clearInterval(intervalRef.current);
22        intervalRef.current = null;
23      }
24    }
25  }, [startFetching]);
26
27  const handleToggleStartFetching = () => {
28    setStartFetching((prev) => !prev);
29  };
30  return (
31    <div>
32      <h1>Top 10 latest updated javascript repositories </h1>
33      <button onClick={handleToggleStartFetching}>Toggle Fetching</button>
34      {startFetching ? (
35        <p>Fetching Github Repositories</p>
36      ) : (
37        <p>Not Fetching Github Repositories</p>
38      )}
39      <ol>
40        {repositories.length > 0 &&
41          repositories.map((repository) => (
42            <li key={repository.id}>
43              {repository.name} by {repository.owner.url}
44            </li>
45          ))}
46      </ol>
47    </div>
48  );
49}
tsx

Alright, let's break the code above.

  1. We have two states, startFetching to indicate whether fetching should start or stop. And repositories where we hold the list of fetched repositories
  2. We have a reference to setInterval id in intervalRef, we need this to stop the setInterval to execute.
  3. The useEffect is to used to run the fetching script when startFetching is changed, either to true or false.
  4. And we have a button element to toggle startFetching to true or false and a repository list to show the fetched repository list.
Testing it out
1npm run dev
bash


Open your browser and navigate to http://localhost:5173 (or the port specified in your terminal) to see the application in action.

Easy Enough, Right?

Congratulations! 🎉 You have just mastered the useEffect function.

BUT not yet!

what happens if you hide TopRepository component while startFetching still true, let's find out:

Uh-oh! We unmounted the component by hiding Top Repository component, but the fetching interval is still.. working!

Because setInterval doesn't know if the component is still mounted or not.

This is the infamous case of "memory leak" when using useEffect.

Handling the Memory Leak

Now to handle this, we need to handle "cleanup" within useEffect, if you go back to top, we have this code to cleanup:

1// ...
2return () => {
3  // cleanup code
4};
5// ...
tsx


The useEffect hook invokes the returned function when the component is unmounted or before the effect runs again due to dependency change

Then how to fix the memory leak? it's easy, you just need to clear up the interval, just as we stop the interval when startFetching is false.

1useEffect(() => {
2  const fetchData = async () => {
3    console.log("Fetching Github Repositories...");
4    setRepositories((await fetchGithubRepositories()).items);
5    console.log("Fetching Github Repositories done!");
6  };
7
8  if (startFetching) {
9    intervalRef.current = setInterval(fetchData, 5000);
10  }
11
12  return () => {
13    // the cleanup code here will run once the component is unmounted or before the effect runs again due to dependency change
14    if (intervalRef.current) {
15      clearInterval(intervalRef.current);
16      console.log("interval cleared");
17    }
18  };
19}, [startFetching]);
tsx

Alright, now we have implementing the cleanup code. Let's try running the application again!

As show above, the fetch interval is stopped immediately when we unmount the Top Repository component, the memory leak is officially vanquished.

In Conclusion

The React useEffect hook is a powerful utility to manage state and side effect in functional component. When used correctly, it helps avoid performance issues and optimizes resource usage, however misuse can lead to problem like memory leaks that bog down your application.

Key Takeaways

It's very important to understand how useEffect hook work and it's lifecycle especially the cleanup phase. Although I must say cleanup function is optional and only needed when side effects require it (e.g., subscriptions, timers).

And while our poller in this exercise works wonderfully, it's just an exercise to get you the picture of useEffect memory leak, it's just the tip of the iceberg and it needs further optimization for production-grade. If you interested, I suggest you to check SWR or Tanstack Query for the optimal performance and advanced features.

Happy coding, and may your useEffect hooks always be clean and leak-free! 🚀