React useEffect Cleanup Function
Table of Contents + −
In the last lesson you learned about React useEffect Dependencies, so you know when an effect runs. This lesson is about the other half of useEffect: stopping what an effect started, like a timer or a listener, before it piles up and breaks your app.
🤔 Why do we need cleanup?
Here’s the pain. Some effects don’t just read data, they start something that keeps going on its own.
- You start a timer with
setInterval. It keeps firing forever, even after the component is gone. - You add a click listener to the window. It stays attached, even after you no longer need it.
- You open a live connection, like a chat socket. It stays open and keeps receiving messages.
The trouble is the effect runs again on the next render, so it starts a second one. Now you have two timers, then three, then more.
- Old timers keep firing, so you get strange double or triple updates.
- Listeners pile up, so one click runs your code many times.
- These leftovers waste memory and battery too. That’s a memory leak, where things you don’t need anymore are still held in memory and still running.
So you need a way to say “before you start a fresh one, stop the old one”. That’s exactly what a cleanup function does.
🧹 What is a cleanup function?
A cleanup function is a function you return from inside useEffect. React calls it for you to undo whatever the effect set up.
- Inside your effect you do the setup, like starting a timer.
- Then you
returna small function that stops that timer. - React holds onto that returned function and runs it at the right moment.
Think of it like a key and a lock. The setup turns the key to start something. The cleanup function turns it back to stop the same thing.
🧩 The shape of an effect with cleanup
Here’s the basic pattern you’ll write again and again. Notice the return line inside the effect.
This effect does some setup, then returns a function that undoes it.
useEffect(() => { // setup: start a timer, add a listener, open a connection const id = setInterval(doSomething, 1000);
// cleanup: React runs this to undo the setup return () => { clearInterval(id); };}, [deps]);So the effect itself is the setup, and the function you return is the cleanup. Here’s the key bit:
- React keeps that returned function and calls it later, when it’s time to clean up.
- You never call it yourself, React does.
⏰ When does cleanup run?
This is the key idea, so let’s be clear about the two moments React runs your cleanup.
- Before the effect runs again. When a dependency changes and the effect is about to run a second time, React first runs the old cleanup. So it stops the old timer, then starts the new one. You never stack two.
- When the component unmounts. When the component is removed from the screen, React runs the cleanup one last time. So nothing is left running after the component is gone.
A simple way to remember it. Cleanup runs right before the next setup, and once at the very end. So there is always exactly one active thing at a time, never a pile.
The order is stop-then-start
On a re-run, React always cleans up the old effect before running the new one. So you can count on the old timer or listener being gone before a fresh one is created.
⏱️ The classic example: setInterval
The timer is the example that makes cleanup click. Let’s build a counter that ticks up every second.
Here we start the interval in the effect and return a cleanup that stops it.
import { useState, useEffect } from "react";
function Timer() { const [seconds, setSeconds] = useState(0);
useEffect(() => { const id = setInterval(() => { setSeconds((s) => s + 1); }, 1000);
return () => clearInterval(id); }, []);
return <p>Seconds: {seconds}</p>;}Let’s walk through what happens here.
setIntervalstarts a timer that calls our function once every 1000 milliseconds, so once a second.- It returns an
id, which is just a handle that points to this exact timer. - We
return () => clearInterval(id), so when cleanup runs it stops that timer using its id. - The empty array
[]means the effect runs once when the component appears. So we start one timer, and the cleanup stops it when the component goes away.
So the counter ticks up by one each second, and the moment the component leaves the screen, the timer stops.
🐛 Without cleanup the timer never stops
Now let’s see why the cleanup matters. Watch what happens when you forget it.
This pair shows the broken version and the fixed version side by side.
// ❌ Wrong - no cleanup, the timer keeps running foreveruseEffect(() => { setInterval(() => { setSeconds((s) => s + 1); }, 1000);}, []);
// ✅ Right - return a cleanup that stops the timeruseEffect(() => { const id = setInterval(() => { setSeconds((s) => s + 1); }, 1000); return () => clearInterval(id);}, []);The difference is what happens when the component goes away or the effect runs again.
- In the ❌ version, nothing stops the interval. The component can be removed from the screen and the timer still keeps firing in the background, trying to update state that’s gone.
- React even warns you about updating a component that’s no longer there. And if the effect runs again, you now have two timers counting at once.
- In the ✅ version, the returned cleanup calls
clearInterval(id). So the old timer always stops before anything new starts. There’s only ever one.
Every setInterval needs a clearInterval
Any timer you start inside an effect must be stopped in the cleanup. The same
goes for setTimeout paired with clearTimeout. If you start it, you clean it
up.
👂 Cleaning up an event listener
Timers aren’t the only thing that needs cleanup. Event listeners are the other big one. Let’s track the window width.
Here we add a resize listener in the effect and remove it in the cleanup.
import { useState, useEffect } from "react";
function WindowWidth() { const [width, setWidth] = useState(window.innerWidth);
useEffect(() => { function handleResize() { setWidth(window.innerWidth); }
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); }, []);
return <p>Window width: {width}px</p>;}Let’s read through the important parts.
addEventListener("resize", handleResize)tells the window to callhandleResizeevery time the window changes size.- The cleanup calls
removeEventListenerwith the same function, so React detaches the listener we added. - Passing the same
handleResizematters. removeEventListener only removes a listener if you give it the exact same function. - Without the cleanup, every re-mount adds another
resizelistener on top of the old ones, so one resize runs your code many times.
Add and remove the same function
Always give removeEventListener the same named function you passed to
addEventListener. An inline arrow function won’t work, because each one is a
new function and React can’t match it to remove.
📡 Cancelling subscriptions
The same idea covers anything that stays “live”, like a subscription or an open connection. You start it in the effect, you cancel it in the cleanup.
Here we subscribe to some outside data source and unsubscribe in the cleanup.
useEffect(() => { const subscription = chatRoom.subscribe(handleMessage);
return () => subscription.unsubscribe();}, [roomId]);So the pattern is always the same shape.
- The effect opens the connection, so messages start coming in.
- The cleanup calls
unsubscribe(), so the connection closes and messages stop. - Because
roomIdis in the dependency array, switching rooms runs the cleanup first. So it leaves the old room before joining the new one. You’re never connected to two rooms at once.
That’s the whole reason cleanup runs before the next effect. It lets you tidy up the old thing before setting up the new one.
🧩 What You’ve Learned
- ✅ A cleanup function is a function you
returnfrom insideuseEffect - ✅ It undoes whatever the effect set up, like a timer, a listener, or a subscription
- ✅ Cleanup runs before the effect runs again, and once when the component unmounts
- ✅ React always cleans up the old effect before running the new one, so you never stack two
- ✅ Start a timer with
setInterval, then stop it withclearIntervalin the cleanup - ✅ Add a listener with
addEventListener, then remove it withremoveEventListenerusing the same function - ✅ Cancel subscriptions in the cleanup so old connections close
- ✅ Without cleanup you get leftover timers, piled-up listeners, memory leaks, and strange bugs
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
How do you create a cleanup function in useEffect?
Why: You return a function from inside the effect callback. React keeps that returned function and runs it when it's time to clean up.
- 2
When does React run the cleanup function?
Why: Cleanup runs right before the effect runs again on a re-run, and one final time when the component is removed from the screen.
- 3
What happens if you start a setInterval in an effect but never clear it?
Why: Without clearInterval in the cleanup, the timer keeps firing after the component is removed, tries to update state that's gone, and can stack up if the effect runs again.
- 4
Why must removeEventListener get the same function you passed to addEventListener?
Why: removeEventListener matches by the exact function reference. If you pass a new inline arrow function, it won't match the one you added, so the listener stays attached.
🚀 What’s Next?
Now you can clean up timers, listeners, and subscriptions so your effects never leave anything running. Next you’ll use useEffect for one of its most common jobs: fetching data from a server when your component loads.