useEffect hooks that set up subscriptions, timers, or event listeners without cleanup cause memory leaks, stale state updates, and race conditions.
Impact: HIGH (prevents memory leaks, stale state updates on unmounted components, and race conditions)
Every useEffect that creates a subscription, timer, event listener, or starts an async operation needs a cleanup function. Without cleanup:
This is the single most common React bug found in code reviews.
Incorrect (missing cleanup):
'use client'
// ❌ Event listener added on every render, never removed
useEffect(() => {
window.addEventListener('resize', handleResize)
}, [])
// ❌ Interval runs forever, even after unmount
useEffect(() => {
setInterval(() => {
setCount(c => c + 1)
}, 1000)
}, [])
// ❌ Fetch race condition — if component re-renders, both requests resolve
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => setUser(data)) // May set state on unmounted component
}, [userId])
// ❌ WebSocket never closed
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/feed')
ws.onmessage = (event) => setMessages(prev => [...prev, event.data])
}, [])Correct (cleanup functions that prevent leaks):
'use client'
// ✅ Event listener: add and remove
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// ✅ Interval: clear on unmount
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
// ✅ Fetch: abort on cleanup to prevent race conditions
useEffect(() => {
const controller = new AbortController()
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') throw err // Ignore abort errors
})
return () => controller.abort()
}, [userId])
// ✅ WebSocket: close on unmount
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/feed')
ws.onmessage = (event) => setMessages(prev => [...prev, event.data])
return () => ws.close()
}, [])
// ✅ Third-party subscription: unsubscribe
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
setLocalState(state)
})
return unsubscribe
}, [])Rule of thumb: if you see addEventListener, setInterval, setTimeout, subscribe, new WebSocket, new EventSource, or fetch inside a useEffect, there must be a corresponding cleanup in the return function.
Detection hints:
# Find useEffects with subscriptions but no cleanup return
grep -rn "useEffect" src/ --include="*.tsx" --include="*.ts" -A 5 | grep -B 2 "addEventListener\|setInterval\|subscribe\|WebSocket"Reference: React useEffect Cleanup · React Docs: You Might Not Need an Effect
prevents memory leaks, stale state updates on unmounted components, and race conditions
BeforeMerge scans your pull requests against this rule and 6+ others. Get actionable feedback before code ships.