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]);
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.
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 />
Everytime we click the "change message" button it will invoke the function inside useEffect
. This is how basically useEffect
is used.
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 />
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! 🚀
useEffect
by building a HTTP Request PollerBefore we embark on the exercise, make sure you:
type
or interface
declaration.useState
hook.setInterval
function via window.setInterval.The requirements:
useEffect
and memory leak, no need for error handling or fancy loading indicators.5
seconds.The technical requirements:
https://api.github.com/search/repositories?q=language:javascript&sort=updated&order=desc&per_page=10
useEffect
)Tech stack:
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}
Note: this is a slimmed-down type definition. The real one is much more detailed, but we're keeping it simple for our purposes
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}
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;
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!
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}
Alright, let's break the code above.
startFetching
to indicate whether fetching should start or stop. And repositories
where we hold the list of fetched repositoriessetInterval
id in intervalRef
, we need this to stop the setInterval
to execute.useEffect
is to used to run the fetching script when startFetching
is changed, either to true
or false
.startFetching
to true
or false
and a repository list to show the fetched repository list.1npm run dev
Open your browser and navigate to http://localhost:5173 (or the port specified in your terminal) to see the application in action.
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
.
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// ...
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]);
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.
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.
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! 🚀