We can all agree that maintaining a clean and efficient codebase is crucial, especially when working with React components where the risk of memory leaks can occur.
That's why we've compiled a list of insider tips and tricks that will change the way you approach React component cleanup. Tips that are designed to keep your application running smoothly and efficiently, far beyond the basics.
This blog post is about Component Cleanup!
I'll share these exclusive strategies, demystifying the cleanup process and helping you to avoid common pitfalls that even experienced developers might not be aware of.
Prerequisite
This article follows on from a previous one on my blog. If you haven't read it yet, I recommend you take a look anyway, as it demonstrates How to Run Code when Mounting a Component on React đź‘€.
Clean up without dependencies
We've been learning how to start long-running processes on mount with the useEffect
hook, but our solutions so far have been incomplete!
To show the problem, I've updated our mouse-tracking example from the How to Run Code On Mount article section đź‘€ so that it's conditionally rendered:
function App() {
const [isTrackingMouse, setIsTrackingMouse] = React.useState(true);
function toggleMouseTracking() {
setIsTrackingMouse(!isTrackingMouse);
}
return (
<div className="wrapper">
<button onClick={toggleMouseTracking}>
Toggle Mouse Tracking
</button>
{isTrackingMouse && <MouseTracker/>}
</div>
);
}
When isTrackingMouse
is a truthy value, we mount the component. This creates a component instance, a place for us to store data related to the component.
When isTrackingMouse
is falsy, however, we unmount the component, destroying the instance and removing all associated DOM nodes.
It's intuitive to think, therefore, that any lingering effects will also be interrupted if the component unmounts, but unfortunately, it doesn't work this way.
Our MouseTracker
component sets up the following effect:
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
}, []);
When the component unmounts, the event listener remains, tracking the cursor position and calling setMousePosition
on a component instance that shouldn't exist anymore!
This is a problem for 2 reasons:
- Every time the component is re-mounted, another event listener will be added, without the previous one being removed.
- Because we're referencing a part of the component instance (by calling
setMousePosition
), the JavaScript garbage collector isn't able to clean up this instance! That means that every time we mount this component, we create an instance that will never be erased.
This is known as a memory leak. The longer the person spends using our application, the more memory it will consume. The memory will be released if the user refreshes the page, but often people will leave the same tab running for weeks or months on end!
Why isn't the event listener removed automatically? Here's the thing: React actually has no idea what goes on inside our effect function:
// What React sees:
React.useEffect(() => {
????
}, []);
React sees that we've given it a function, and we've specified when it should be called (with the dependency array), but React can't “see inside” this function! It has no idea that we've started an event listener, since the event-listener stuff is part of the DOM.
Similarly, the JavaScript engine that runs our event listener has no idea that it was created within a component instance, and that it should be contingent on that instance existing.
Essentially, we have two independent systems here, and it's up to us to synchronize them.
Fortunately, the useEffect
comes with a tool to make this possible: cleanup functions.
Here's what it looks like:
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
}
}, []);
Within our effect function, we return a function that contains the cleanup work to be done. React will hang onto this function, and invoke it at the appropriate time: right before the component unmounts.
This is typically the pattern for any subscription / long-running process. We subscribe in the effect function, and unsubscribe in the cleanup function. React will call the cleanup function right before the component unmounts, stopping the process and ensuring we don't wind up with a memory leak.
Here's the sandbox from the video, with the cleanup function added:
One of the most confusing things to me, earlier in my career, was when functions would return functions. Stuff like this would make my head spin. Something similar is happening here with the
useEffect
cleanup API. Our main effect function is returning a function.Here's the good news: This is really more of an implementation detail. We don't need to get too tripped up on this curious bit of syntax.
The most important thing is that you understand the fundamental idea: We give React two functions, an effect function and a cleanup function, and React calls these functions for us at the appropriate time.
Clean up with dependencies
In the example above, we saw how to start and stop a long-running effect, tied to the component lifecycle. But what if our effect has dependencies? How do they interact with this cleanup function?
Let's look at an updated example. Instead of having a parent component that mounts/unmounts the MouseTracker
component, what if everything happens within the component?
Here's the new code:
import React from 'react';
function MouseTracker() {
const [mousePosition, setMousePosition] = React.useState({
x: 0,
y: 0,
});
const [isEnabled, setIsEnabled] = React.useState(true);
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
function toggleMouseTracking() {
setIsEnabled(!isEnabled);
}
return (
<>
<button onClick={toggleMouseTracking}>
Toggle Mouse Tracking
</button>
<p> {mousePosition.x} / {mousePosition.y} </p>
</>
);
}
export default MouseTracker;
Essentially, I want to register the event listener when isEnabled
is true
, and unregister it when the user clicks the button, flipping isEnabled
to false
.
When I was first getting started with effects, my intuition was to do something like this:
// ❌ This code will lead to an error
if (isEnabled) {
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
}
It made so much sense to me, to conditionally call the useEffect
hook. Unfortunately, as we've learned, this violates the “Rules of Hooks”. We're not allowed to conditionally call any hooks.
Well, what if we move the if
condition within the hook, like this?
React.useEffect(() => {
if (isEnabled) {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}
}, []);
If we run this code, we get a different lint warning: we're missing a dependency! Like we saw in the Effect Lint Rules lesson, we need to add all state variables to the dependency array.
When we add isEnabled
to the dependency array, we solve all of the lint warnings... and we also solve the problem!
// âś… This code does exactly what we want!
React.useEffect(() => {
if (isEnabled) {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}
}, [isEnabled]);
How exactly does this work, though?
- When the component mounts, we run the effect, registering the event listener, and handing React the cleanup function, like a gift waiting to be opened
- As the user moves the mouse around, the
mousePosition
state will be updated rapid-fire, but the effect doesn't re-run, sincemousePosition
isn't a dependency. - If the user clicks the button,
isEnabled
flips to false. SinceisEnabled
is a dependency, it means the effect will re-run. - First, though, React invokes the cleanup function!
- The event listener is removed, and because
isEnabled
isfalse
, the effect doesn't do anything. - This process repeats every time the user clicks the button.
The key trick here is that effects aren't meant to "stack". Before React can re-run the effect, it'll invoke the cached cleanup function, to make sure we're starting from a "clean slate".
There are several diagrams illustrating this concept. They can be viewed below:
The order of operations:

A view of snapshots and cleanup:

Here is the final sandbox for this section:
Wrap up
We just went through everything you need to know about cleaning up React components - stuff that most tutorials don't even mention!
We talked about why it's super important to clean up after yourself to avoid memory leaks and keep your app running smoothly. You know those tricky event listeners that can build up and slow things down? Well, now you've got the skills to handle them the right way!
Here's what you really need to remember: Make sure to clean up after your React components, keep an eye on what your code depends on, and let React handle when to do the cleanup - it's pretty smart about that!
🔍. Similar posts
How to Run Code on Mount in Your React Components Easily
04 Mar 2025
Best Practices for Using the useEffect Hook in React
22 Feb 2025
How to Effectively Use the React useId Hook in Your Apps
16 Feb 2025