How to clean up useEffect.
Timers, fetch, subscriptions.
Every useEffect that starts something must clean it up. Timers that fire after unmount, fetches that resolve on dead components, event listeners that stack up on every re-render — all of these are leaks. The fix is the cleanup function. This page covers the three patterns you'll use daily.
Short answer: Return a function from useEffect. React calls it before the next run and on unmount. Timers: clearInterval(id) / clearTimeout(id). Fetch: controller.abort() + a cancelled flag. Subscriptions: unsubscribe() from the library. Event listeners: removeEventListener. If the effect doesn't start anything, no cleanup needed.
✅ Solution 1 — Timers (setInterval / setTimeout)
The most common cleanup case.
If the effect starts a timer and doesn't clear it, the timer keeps firing after the component unmounts. You get "setState on unmounted component" warnings, or worse, memory leaks. Fix: capture the timer ID in a local variable inside the effect and clear it in the cleanup.
import { useState, useEffect } from 'react'; // ─── BAD: timer keeps running after unmount ── function BadTimer() { const [count, setCount] = useState(0); useEffect(() => { setInterval(() => { setCount(c => c + 1); // ⚠️ fires after unmount }, 1000); }, []); // ❌ No cleanup — interval runs forever, warning on unmount return{count}
; } // ─── GOOD: setInterval + clearInterval ─────── function GoodTimer() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); // Cleanup — runs on unmount AND before re-run return () => clearInterval(id); }, []); // ✅ cleans up correctly return{count}
; } // ─── GOOD: setTimeout + clearTimeout ───────── function DebouncedSearch({ query }: { query: string }) { const [results, setResults] = useState<string[]>([]); useEffect(() => { const id = setTimeout(() => { fetch(/api/search?q=${query}`) .then(r => r.json()) .then(setResults); }, 300); // If query changes within 300ms, cancel the previous timeout return () => clearTimeout(id); }, [query]); return
- {results.map(r =>
- {r} )}
{remaining}s remaining
; } console.log(BadTimer, GoodTimer, DebouncedSearch, Countdown);import { useState, useEffect } from 'react'; function BadTimer() { const [count, setCount] = useState(0); useEffect(() => { setInterval(() => { setCount(c => c + 1); }, 1000); }, []); return React.createElement('p', null, count); } function GoodTimer() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return React.createElement('p', null, count); } function DebouncedSearch({ query }) { const [results, setResults] = useState([]); useEffect(() => { const id = setTimeout(() => { fetch(/api/search?q=${query}`).then(r => r.json()).then(setResults); }, 300); return () => clearTimeout(id); }, [query]); return React.createElement('ul', null, results.map(r => React.createElement('li', { key: r }, r))); } function Countdown({ seconds }) { const [remaining, setRemaining] = useState(seconds); useEffect(() => { setRemaining(seconds); const id = setInterval(() => { setRemaining(r => { if (r <= 1) { clearInterval(id); return 0; } return r - 1; }); }, 1000); return () => clearInterval(id); }, [seconds]); return React.createElement('p', null, remaining, 's remaining'); } console.log(BadTimer, GoodTimer, DebouncedSearch, Countdown);
Capture the ID locally
const id = setInterval(...) — the ID must be in scope for the cleanup. Don't store it in a ref or state.
Cleanup runs before re-run
If deps change, the old timer is cleared before the new one starts. No overlapping timers.
Debounce pattern
setTimeout + clearTimeout on every keystroke = only the last request fires. The classic debounce pattern.
No cleanup needed if no timer
If the effect just logs or calls a function, no cleanup. Cleanup is for resources — timers, listeners, subscriptions.
✅ Solution 2 — Fetch with AbortController
Cancel the request AND prevent stale setState.
A fetch that resolves after the component unmounts calls setState on a dead component. React warns (in older versions) or silently ignores (in React 18+). Fix: cancel the fetch with AbortController and set a cancelled flag to bail out early. Both are needed — the abort cancels the network request, the flag prevents the setState.
import { useState, useEffect } from 'react'; interface User { id: string; name: string } // ─── BAD: no cleanup, race condition ───────── function BadFetch({ userId }: { userId: string }) { const [user, setUser] = useStatenull>(null); useEffect(() => { fetch(/api/users/${userId}`) .then(r => r.json()) .then(setUser); }, [userId]); // ❌ If userId changes fast, old fetch might resolve AFTER new one // → user shows the wrong data (race condition) // ❌ If component unmounts, setUser runs on dead component return {user?.name ?? 'Loading...'}
; } // ─── GOOD: AbortController + cancelled flag ── function GoodFetch({ userId }: { userId: string }) { const [user, setUser] = useStatenull>(null); const [error, setError] = useState<string | null>(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setUser(null); setError(null); fetch(/api/users/${userId}`, { signal: controller.signal }) .then(r => r.json()) .then(data => { if (!cancelled) setUser(data); }) .catch(err => { // AbortError is expected — ignore it if (err.name !== 'AbortError' && !cancelled) { setError(err.message); } }); return () => { cancelled = true; controller.abort(); }; }, [userId]); // ✅ no race, no setState on unmounted if (error) return Error: {error}
; return{user?.name ?? 'Loading...'}
; } // ─── Pattern: reusable useFetch hook ───────── function useFetch(url: string) { const [data, setData] = useState null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setLoading(true); setError(null); fetch(url, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(HTTP ${r.status}`); return r.json(); }) .then(d => { if (!cancelled) { setData(d); setLoading(false); } }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; controller.abort(); }; }, [url]); return { data, loading, error }; } // ─── Usage ─────────────────────────────────── function UserProfile({ userId }: { userId: string }) { const { data, loading, error } = useFetch (/api/users/${userId}`); if (loading) return Loading...
; if (error) returnError: {error}
; return{data?.name}
; } console.log(BadFetch, GoodFetch, useFetch, UserProfile);
import { useState, useEffect } from 'react'; function BadFetch({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetch(/api/users/${userId}`).then(r => r.json()).then(setUser); }, [userId]); return React.createElement('p', null, user?.name ?? 'Loading...'); } function GoodFetch({ userId }) { const [user, setUser] = useState(null); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setUser(null); setError(null); fetch(/api/users/${userId}`, { signal: controller.signal }) .then(r => r.json()) .then(data => { if (!cancelled) setUser(data); }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) setError(err.message); }); return () => { cancelled = true; controller.abort(); }; }, [userId]); if (error) return React.createElement('p', null, 'Error: ', error); return React.createElement('p', null, user?.name ?? 'Loading...'); } function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setLoading(true); setError(null); fetch(url, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(HTTP ${r.status}`); return r.json(); }) .then(d => { if (!cancelled) { setData(d); setLoading(false); } }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; controller.abort(); }; }, [url]); return { data, loading, error }; } function UserProfile({ userId }) { const { data, loading, error } = useFetch(/api/users/${userId}`); if (loading) return React.createElement('p', null, 'Loading...'); if (error) return React.createElement('p', null, 'Error: ', error); return React.createElement('p', null, data?.name); } console.log(BadFetch, GoodFetch, useFetch, UserProfile);
AbortController
fetch(url, { signal: controller.signal }) — the controller can cancel the request. Call controller.abort() in cleanup.
cancelled flag
The abort rejects the promise. The flag prevents setState from firing even if the promise resolves. Both work together.
Ignore AbortError
The abort throws an AbortError. Don't show it as a real error. Check err.name !== 'AbortError'.
Race conditions
User clicks fast — old fetch might resolve after new one. Abort prevents this: the old request is cancelled before the new one starts.
✅ Solution 3 — Subscriptions and event listeners
Third-party libraries, WebSockets, global events.
Subscriptions to external systems — WebSocket connections, event buses, global window events, third-party APIs — must be cleaned up. Otherwise the subscription accumulates on every re-render, and the callback fires after unmount. Fix: capture the unsubscribe function or listener reference, and call it in cleanup.
import { useState, useEffect } from 'react'; // ─── Event listener ────────────────────────── function WindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { function handleResize() { setSize({ width: window.innerWidth, height: window.innerHeight }); } window.addEventListener('resize', handleResize); // ✅ Remove the SAME function reference return () => window.removeEventListener('resize', handleResize); }, []); return{size.width} × {size.height}
; } // ─── WebSocket ─────────────────────────────── function LiveTicker() { const [price, setPrice] = useState(0); useEffect(() => { const ws = new WebSocket('wss://api.example.com/ticks';); ws.onmessage = (e) => { const data = JSON.parse(e.data); setPrice(data.price); }; // ✅ Close the connection return () => ws.close(); }, []); return$`{price}
; } // ─── Custom event bus ──────────────────────── type Listener = (data: unknown) => void; class EventBus { private listeners = new Set(); subscribe(fn: Listener): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } emit(data: unknown) { this.listeners.forEach(fn => fn(data)); } } const bus = new EventBus(); function BusListener() { const [last, setLast] = useState<unknown>(null); useEffect(() => { // ✅ subscribe returns unsubscribe const unsubscribe = bus.subscribe(setLast); return unsubscribe; }, []); return Last event: {JSON.stringify(last)}
; } // ─── Interval with dependency ──────────────── function Clock({ timezone }: { timezone: string }) { const [time, setTime] = useState(''); useEffect(() => { function tick() { setTime(new Date().toLocaleTimeString('en-US', { timeZone: timezone })); } tick(); // immediate first render const id = setInterval(tick, 1000); return () => clearInterval(id); }, [timezone]); return{time}
; } // ─── Intersection Observer ─────────────────── function LazySection() { const [visible, setVisible] = useState(false); const ref = useRef(null); useEffect(() => { if (!ref.current) return; const observer = new IntersectionObserver( ([entry]) => setVisible(entry.isIntersecting), { threshold: 0.1 } ); observer.observe(ref.current); // ✅ Disconnect the observer return () => observer.disconnect(); }, []); return {visible ? 'Visible' : 'Hidden'}; } import { useRef } from 'react'; console.log(WindowSize, LiveTicker, BusListener, Clock, LazySection);
import { useState, useEffect, useRef } from 'react'; function WindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { function handleResize() { setSize({ width: window.innerWidth, height: window.innerHeight }); } window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return React.createElement('p', null, size.width, ' × ', size.height); } function LiveTicker() { const [price, setPrice] = useState(0); useEffect(() => { const ws = new WebSocket('wss://api.example.com/ticks';); ws.onmessage = (e) => { const data = JSON.parse(e.data); setPrice(data.price); }; return () => ws.close(); }, []); return React.createElement('p', null, '`$', price); } class EventBus { constructor() { this.listeners = new Set(); } subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } emit(data) { this.listeners.forEach(fn => fn(data)); } } const bus = new EventBus(); function BusListener() { const [last, setLast] = useState(null); useEffect(() => { const unsubscribe = bus.subscribe(setLast); return unsubscribe; }, []); return React.createElement('p', null, 'Last event: ', JSON.stringify(last)); } function Clock({ timezone }) { const [time, setTime] = useState(''); useEffect(() => { function tick() { setTime(new Date().toLocaleTimeString('en-US', { timeZone: timezone })); } tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, [timezone]); return React.createElement('p', null, time); } function LazySection() { const [visible, setVisible] = useState(false); const ref = useRef(null); useEffect(() => { if (!ref.current) return; const observer = new IntersectionObserver( ([entry]) => setVisible(entry.isIntersecting), { threshold: 0.1 } ); observer.observe(ref.current); return () => observer.disconnect(); }, []); return React.createElement('div', { ref }, visible ? 'Visible' : 'Hidden'); } console.log(WindowSize, LiveTicker, BusListener, Clock, LazySection);
Event listener: same reference
addEventListener('resize', handler) → cleanup must call removeEventListener('resize', handler) with the same function. Define it inside the effect.
WebSocket: close
ws.close() in cleanup. Otherwise the connection stays open and calls onmessage on unmounted components.
Subscribe: return the unsubscribe
Most libraries return an unsubscribe from subscribe(). Return it directly — return unsubscribe is the cleanup.
Observer: disconnect
IntersectionObserver, MutationObserver, ResizeObserver — all need .disconnect() in cleanup.
Cleanup functions capture the values from their render. The handler you passed to addEventListener must be the same reference you pass to removeEventListener. If you inline it, or recreate it in cleanup, the remove call silently does nothing — and the listener leaks. Always define the handler inside the effect and reference it in both add and remove. This is the #1 source of "why does my handler fire twice" bugs.
⚠️ Common mistakes
The cleanup mistakes that cost real time.
Timers, fetches, subscriptions all leak. Fix: always return a cleanup function that reverses the setup.
addEventListener('click', () => ...) then removeEventListener('click', () => ...) — different references. Fix: define the handler once inside the effect.
fetch().then(setData) — updates state on unmounted components. Fix: AbortController + cancelled flag.
Timer created with setInterval cleared with clearTimeout. Fix: match setInterval/clearInterval, setTimeout/clearTimeout.
const idRef = useRef() inside the effect — unnecessary if cleanup captures the local. Fix: use a local const id.
If cleanup throws, React can't unmount properly. Fix: wrap in try/catch, or ensure cleanup is safe.
if (enabled) { subscribe() } — cleanup must also be conditional. Fix: capture both setup and teardown in the same effect.
StrictMode mounts twice in dev — cleanup runs between. If setup isn't idempotent, bugs surface. Fix: ensure cleanup fully reverses setup.
❓ Frequently asked
How do I clean up a useEffect in React?
Return a function from useEffect. React calls it before the next run of the effect, and on unmount. The cleanup function should reverse whatever the effect started: clearInterval/clearTimeout for timers, controller.abort() for fetch, unsubscribe() for subscriptions.
When does the cleanup function run?
Three cases: (1) when the component unmounts, (2) before the effect re-runs because a dependency changed, (3) never if the effect has an empty dependency array and the component doesn't unmount. The cleanup always sees the values from the render that created the effect.
How do I cancel a fetch request in useEffect?
Create an AbortController, pass its signal to fetch, and call controller.abort() in the cleanup. Additionally, guard setState with a cancelled flag: let cancelled = false; return () => { cancelled = true; controller.abort(); }. Both are needed for full correctness.
Do I need cleanup for every useEffect?
No. Effects that don't start anything (logging, calling a function, computing a value) don't need cleanup. Effects that start something — timers, subscriptions, network requests, animations, event listeners — must clean up.