useEffect
is one of the most commonly used hooks in React. It's designed to handle side effects - that is, actions that don't directly affect component rendering, but are still necessary, such as fetching data from an API, adding event listeners, handling timers, and more.
But did you know that useEffect
can (and sometimes should) clean up its own effects? This is done using a cleanup function, which many people overlook - until strange bugs start popping up in their code.
What is a Cleanup Function?
A cleanup function is a function returned from within useEffect
, and it gets executed either when the component unmounts or before the effect runs again.
Basic syntax:
useEffect(() => {
// effect code
return () => {
// cleanup code
};
}, []);
When to use a Cleanup function?
You should use cleanup when:
- You add event listeners (and need remove them later).
- You use timers like
setTimeout
orsetInterval
. - You open websockets, subscriptions, or any resource that must be closed.
- You perfom asynchronous setState that could case errors on unmounted components.
When not to use a cleanup function?
- If the effect doesn’t need to be undone or canceled (e.g., a
console.log
or a simplefetch
), there’s no need to return a cleanup function. - Avoid using
useEffect
unnecessarily - especially with empty dependencies ([]
) just to run something once. In many cases, this logic can live outsideuseEffect
if it’s not reactive.
Benefits of proper cleanup
- ✅ Prevents memory leaks.
- ✅ Avoids hard-to-debug issues, like functions being triggered multiple times unexpectedly.
- ✅ Ensures predictable behavior in your components
Practical Examples
1. Clearing a timer
useEffect(() => {
const id = setInterval(() => {
console.log('Running...');
}, 1000);
return () => {
clearInterval(id);
console.log('Timer cleared');
};
}, []);
2. Removing event listeners
useEffect(() => {
const handleMove = (e) => console.log(e.clientX, e.clientY);
window.addEventListener('mousemove', handleMove);
return () => {
window.removeEventListener('mousemove', handleMove);
};
}, []);
3. Canceling WebSocket connections
useEffect(() => {
const socket = new WebSocket('wss://example.com/chat');
socket.onmessage = (msg) => console.log('Message:', msg.data);
return () => {
socket.close();
};
}, []);
4. Avoiding setState
on unmounted components
useEffect(() => {
let canceled = false;
fetch('/api/data')
.then(res => res.json())
.then(data => {
if (!canceled) setData(data);
});
return () => { canceled = true; };
}, []);
Real-world examples in Big Tech
Instagram / Meta - Canceling requests on scroll
useEffect(() => {
const controller = new AbortController();
fetch('/api/feed', { signal: controller.signal })
.then(res => res.json())
.then(data => setFeed(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [userId]);
YouTube - Stopping player on unmount
useEffect(() => {
const player = createVideoPlayer();
return () => {
player.destroy();
};
}, [videoId]);
Slack - WebSocket with reconnects
useEffect(() => {
const socket = new WebSocket('wss://slack.example.com');
socket.onopen = () => console.log('Connected');
socket.onclose = () => console.log('Disconnected');
return () => {
socket.close();
};
}, [workspaceId]);
Extra: Cleanup in Next.js apps
In Next.js, cleanup becomes especially important because:
Route transitions (useRouter
)
import { useRouter } from 'next/router';
useEffect(() => {
const handleRouteChange = (url) => {
console.log('Navigating to:', url);
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, []);
Without cleanup, listeners can stack and fire multiple times.
Shared layout components
In _app.tsx
or layout files, if you have global listeners (e.g. scroll, resize), you must clean them up - or they’ll persist across route changes.
Server-side hydration mismatches
If your effect runs logic that touches window
, document
or 3rd-party APIs during hydration, wrap it in useEffect
and make sure it cleans up - especially if the effect depends on dynamic route params, locale or theme context.
useEffect(() => {
const highlight = () => {
document.querySelectorAll('code').forEach(/* ... */);
};
highlight();
return () => {
// Remove listeners or cleanup DOM changes if needed
};
}, [locale]); // e.g., if syntax highlighting depends on language
Summary
Scenario | Use cleanup? |
---|---|
Timer (setTimeout /setInterval ) |
✅ Yes |
Event listener | ✅ Yes |
WebSocket / subscriptions | ✅ Yes |
Simple fetch without state | ❌ No |
Console logs / animations | ❌ No |
Route listeners (Next.js) | ✅ Yes |
External script usage | ✅ Yes |
Final Thoughts
Cleanup functions in useEffect
are like tidying up after yourself: you might not notice the mess right away - but eventually, you’ll trip over it.
Next time you write an effect, ask yourself:
Do I need to undo this later?
If yes, return a cleanup function.
Your future self (and your users) will thank you.
Top comments (6)
Pretty cool seeing someone actually break this down so clear. I used to skip cleanup way too much and it always got me burned later.
Yeah, same here. I used to ignore it too. It happens sometimes, but whenever I go back to refactor, I remember it.
Super clear breakdown! I've had bugs from missing event listener cleanup before - are there any less obvious scenarios where cleanup bites you?
I wouldn't say they're less obvious, but definitely things we rarely pay attention to. Like aborting a request. I once ran a benchmark where we reduced the number of requests by 50% just by aborting them as soon as the component unmounted.
cleanup is very important.
Great post. Helpful for react devs.
I glad hear this, thanks man
Some comments may only be visible to logged-in visitors. Sign in to view all comments.