Cleanup Functions in useEffect
When useEffect starts something — an interval, an event listener, a network request, a WebSocket connection — it takes responsibility for stopping that thing when it is no longer needed. That is the job of the cleanup function: the optional function you return from your effect.
Why Cleanup Is Necessary
Without cleanup, side effects outlive the components that started them. This causes:
Memory leaks — subscriptions and listeners keep your component in memory even after it unmounts
Stale state updates — a fetch that completes after unmount will try to call
setStateon a dead componentPhantom timers — intervals that keep firing even though the component is gone, potentially causing bugs in other parts of the app
Double-execution bugs — in React 18 Strict Mode, effects run twice in development; without cleanup the second run stacks on top of the first
When the Cleanup Runs
React calls the cleanup function in two situations:
Before re-running the effect — whenever the dependencies change and the effect is about to run again, React first cleans up the previous run
When the component unmounts — the cleanup runs one final time
useEffect(() => {
console.log('Effect ran with:', value)
return () => {
// Runs before the next time this effect runs,
// AND when the component unmounts.
console.log('Cleaning up previous effect with:', value)
}
}, [value])Example 1: Clearing Timers
setTimeout and setInterval both return a timer ID that you pass to clearTimeout/clearInterval to stop them. Without cleanup, the timer keeps firing even after the component is gone.
import { useState, useEffect } from 'react'
// A countdown that cleans up when the component unmounts or delay changes
function Countdown({ seconds }) {
const [remaining, setRemaining] = useState(seconds)
useEffect(() => {
if (remaining <= 0) return
// Start the interval
const id = setInterval(() => {
setRemaining(r => r - 1)
}, 1000)
// Cleanup: stop the interval
return () => clearInterval(id)
}, [remaining])
if (remaining <= 0) return <p>Done!</p>
return <p>{remaining} seconds remaining</p>
}
// A debounce example using setTimeout
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('')
useEffect(() => {
if (!query) return
// Wait 400ms after the user stops typing before searching
const timer = setTimeout(() => {
onSearch(query)
}, 400)
// Cleanup: if query changes before 400ms, cancel the previous timer
return () => clearTimeout(timer)
}, [query, onSearch])
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}In the SearchInput example, every keystroke triggers a new render. React cancels the previous timer before starting a new one, so onSearch is only called once the user pauses for 400ms — a proper debounce.
Example 2: Removing Event Listeners
addEventListener registers a callback globally. If you never call removeEventListener, the callback holds a reference to your component closure forever — even after the component unmounts and the user has navigated away.
import { useState, useEffect } from 'react'
function KeyLogger() {
const [lastKey, setLastKey] = useState(null)
useEffect(() => {
function handleKeyDown(event) {
setLastKey(event.key)
}
window.addEventListener('keydown', handleKeyDown)
// Cleanup: remove the listener when the component unmounts
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, []) // [] = add listener once, remove on unmount
return <p>Last key pressed: {lastKey ?? 'none'}</p>
}
// Multiple event listeners — clean them all up
function DragTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
function handleMouseMove(e) {
setPosition({ x: e.clientX, y: e.clientY })
}
function handleTouchMove(e) {
setPosition({ x: e.touches[0].clientX, y: e.touches[0].clientY })
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('touchmove', handleTouchMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('touchmove', handleTouchMove)
}
}, [])
return <p>Pointer: {position.x}, {position.y}</p>
}Example 3: Cancelling Fetch Requests with AbortController
When userId changes, the old fetch request is no longer needed. If it completes after the new request, it can overwrite the newer data — a race condition. AbortController solves this by cancelling the previous request in the cleanup function.
import { useState, useEffect } from 'react'
function UserCard({ userId }) {
const [user, setUser] = useState(null)
const [status, setStatus] = useState('idle') // 'idle' | 'loading' | 'error'
useEffect(() => {
if (!userId) return
const controller = new AbortController()
setStatus('loading')
fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
setUser(data)
setStatus('idle')
})
.catch(err => {
if (err.name === 'AbortError') {
// Expected — we cancelled it ourselves. Ignore.
return
}
setStatus('error')
})
return () => {
// Cancel the in-flight request when:
// - userId changes (new request is starting), or
// - the component unmounts
controller.abort()
}
}, [userId])
if (status === 'loading') return <p>Loading user...</p>
if (status === 'error') return <p>Failed to load user</p>
if (!user) return null
return <p>{user.name}</p>
}Without the AbortController, if you clicked between users quickly, the slowest request would determine what you see last — not the most recent click. With cleanup, each new userId cancels the previous fetch before starting a new one.
StrictMode Reveals Missing Cleanups
In React 18 development mode, React Strict Mode intentionally mounts → unmounts → remounts every component. This means every effect runs twice in sequence:
Mount → effect runs
Unmount → cleanup runs
Remount → effect runs again
This is not a bug — it is React stress-testing your effects. If your app behaves incorrectly in Strict Mode dev, it means your cleanup is missing or incorrect. Fix the cleanup, not the Strict Mode behavior.
// ❌ Breaks in Strict Mode — adds two listeners, removes only one
useEffect(() => {
window.addEventListener('resize', handleResize) // called twice
// no cleanup!
}, [])
// ✅ Works correctly in Strict Mode — add one, remove one
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])The Cleanup Pattern at a Glance
Every effect that "starts something" should "stop something" in its cleanup:
addEventListener→removeEventListenersetInterval/setTimeout→clearInterval/clearTimeoutfetch(...)→controller.abort()socket.connect()→socket.disconnect()subscribe(handler)→unsubscribe(handler)orsubscription.cancel()observer.observe(el)→observer.disconnect()