Hello! Welcome back to our series on handling side effects in React.
In our last lesson, we made our data-fetching components much more robust by implementing loading and error states. This ensures the user always has clear feedback. However, there's one more critical aspect of side effects we need to address: what happens when a component unmounts?
Imagine a user navigates away from a page while a slow network request is still pending. The component is gone, but the request eventually completes and tries to update the state of a component that no longer exists. This is a "memory leak," and it can lead to application errors and performance degradation.
Today's lesson focuses on solving this exact problem. Your goal is to use a useEffect cleanup function to prevent memory leaks, for example, in subscriptions or API calls. This is the final piece of the puzzle for writing truly professional and resilient side-effect logic.
1. What is a Memory Leak in React?
Before we dive into the solution, let's clarify the problem. In the context of a Single Page Application (SPA) like the ones we're building, components are constantly being mounted and unmounted as the user navigates. A memory leak occurs when your application holds onto resources (like memory for timers, event listeners, or pending operations) that are no longer needed, typically after a component has unmounted.
Handling Memory Leaks in React for Optimal Performance
To get a solid grasp of this concept, let's start with a clear definition of memory leaks in React and their common causes. This article provides a concise overview.
Please read the sections 'What is Memory Leak?' and 'Common Causes of Memory Leaks in React'. This will give you a clear picture of the problems we're trying to solve today.
As you've just read, common culprits include:
- Event listeners that are never removed.
- Timers (
setIntervalorsetTimeout) that are never cleared. - Subscriptions (like WebSockets) that are never closed.
- API requests that try to update state after the component has unmounted.
Fortunately, the useEffect hook has a built-in mechanism to handle all of these scenarios: the cleanup function.
2. The useEffect Cleanup Function
The useEffect hook can optionally return a function. This function is the "cleanup" function. React automatically runs this function at two key moments:
- When the component unmounts: This is the primary use case for preventing memory leaks.
- Before the effect runs again: If the dependencies in the dependency array change, React will run the cleanup function from the previous render before executing the effect for the current render.
Let's watch a short video that demonstrates this concept clearly with a timer example.
React Hooks in ONE Shot 2025 [ EASIEST Explanation ] | React JS Tutorial
This video provides an excellent visual explanation of the cleanup function, why it's needed, and when it runs.
Watch the segment from 00:14:42 to 00:17:24. The first part uses a setInterval example to show a memory leak and how the cleanup function fixes it. The second part uses console.log to demonstrate exactly when the cleanup function is called relative to the effect itself.
The syntax is straightforward. You simply return an arrow function from within your useEffect callback:
useEffect(() => {
// Your side effect logic goes here.
// For example, subscribing to something.
console.log('Effect is running!');
// Return the cleanup function.
return () => {
// Your cleanup logic goes here.
// For example, unsubscribing.
console.log('Cleanup is running!');
};
}, [/* dependencies */]);
3. Common Use Cases for Cleanup Functions
Now, let's look at how to apply this cleanup pattern to the common causes of memory leaks.
React useEffect Cleanup Function - Refine
This article from Refine provides a great 'cookbook' of examples for different cleanup scenarios. We'll use it as a reference as we walk through the most important ones.
Skim through the sections under 'When to use the useEffect cleanup function'. Specifically, look at the code examples for 'Timeouts', 'Intervals', 'Event Listeners', and 'Fetch requests'. Don't worry about understanding every detail right now; we'll focus on the fetch request example next.
Cleaning Up Timers and Event Listeners
As shown in the resources, the pattern is consistent: whatever "add" or "start" function you call in the effect, you call its corresponding "remove" or "clear" function in the cleanup.
setTimeoutis cleaned up withclearTimeout.setIntervalis cleaned up withclearInterval.window.addEventListeneris cleaned up withwindow.removeEventListener.
Canceling API Requests with AbortController
This is the most relevant use case for us, as it builds directly on our previous lessons. To cancel a fetch request, we use the browser's built-in AbortController API.
The process involves three steps:
- Create a new
AbortControllerinstance inside theuseEffecthook. - Pass the controller's
signalproperty to the options object of yourfetchcall. This links the controller to the request. - In the cleanup function, call
controller.abort(). This signals the browser to cancel the network request.
When a request is aborted, the fetch promise rejects with a specific error named AbortError. We need to check for this error in our catch block to avoid treating a deliberate cancellation as a genuine network failure.
4. Practical Example: Making Our Data Fetching Abortable
Let's refactor the PokemonViewer component from our last lesson to include cleanup logic. This will prevent the "Can't perform a React state update on an unmounted component" warning that occurs if the component unmounts before the fetch completes.
Here is the code from the previous lesson:
// Before: Without cleanup
useEffect(() => {
const fetchPokemon = async () => {
try {
const response = await fetch("https://pokeapi.co/api/v2/pokemon/ditto");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setPokemon(data);
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
};
fetchPokemon();
}, []);
And here is the refactored version with the AbortController:
// After: With cleanup
useEffect(() => {
// 1. Create an AbortController for this effect
const controller = new AbortController();
const fetchPokemon = async () => {
try {
const response = await fetch("https://pokeapi.co/api/v2/pokemon/ditto", {
// 2. Pass the signal to the fetch request
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setPokemon(data);
} catch (e) {
// Check if the error was due to aborting
if (e.name === 'AbortError') {
console.log('Fetch aborted');
} else {
setError(e);
}
} finally {
setLoading(false);
}
};
fetchPokemon();
// 3. Return a cleanup function that aborts the controller
return () => {
controller.abort();
};
}, []);
With this change, if the PokemonViewer component unmounts for any reason, the controller.abort() call will immediately cancel the pending fetch request. The catch block will catch the resulting AbortError, and we can simply ignore it, preventing any further logic (like setError) from running. Our component is now leak-proof.
Conclusion
You've now mastered the final and most advanced aspect of the useEffect hook. By adding cleanup functions, you can write side effects that are safe, efficient, and free from memory leaks.
Key Takeaways:
- A memory leak in React happens when resources are not released after a component unmounts.
- The
useEffecthook provides a cleanup mechanism by allowing you toreturna function. - The cleanup function runs when the component unmounts or before the effect runs again.
- It is essential for cleaning up timers (
clearInterval), event listeners (removeEventListener), and subscriptions. - For API calls, use the
AbortControllerAPI to cancel pendingfetchrequests in the cleanup function.
Preview of the Next Lesson:
Our data-fetching logic is now complete and robust: it handles loading, errors, and cleanup. However, it's a lot of boilerplate code to write in every component that needs data. In our next lesson, we will solve this by learning how to create a reusable custom hook to encapsulate all this logic. This will allow us to fetch data in any component with a single, clean line of code.