React 32 ⚛️ Effect Cleanup Functions
An effect sets something up. A cleanup function tears it down. The cleanup is the other half of the effect, and it is the half that is most often forgotten. A subscription that is never unsubscribed, a timer that is never cleared, a network request that is never aborted, an event listener that is never removed—each of these is a leak that the cleanup function prevents. React calls the cleanup before the next effect run and when the component unmounts. The effect without a cleanup is a resource that is created and never released.
Key point: An effect can return a function. React calls that function before the next effect execution and when the component unmounts. The cleanup should release every resource the effect created: clear timers, unsubscribe, abort requests, remove listeners, close connections. If the effect sets nothing up, it does not need a cleanup. If it sets anything up, the cleanup is not optional.
Why cleanup functions matter
The leak problem. A subscription that is not unsubscribed holds a reference to the component. The component cannot be garbage-collected. The subscription callback fires after the component is gone, calling setState on an unmounted component, which produces a warning and wastes memory. The cleanup prevents the leak.
The double-fire problem. In React 18’s StrictMode, effects run twice on mount in development: once, cleanup, and once again. This is deliberate. It surfaces effects that do not clean up properly, because the second run exposes the state left by the first. An effect without a cleanup that sets up a timer produces two timers in development. The cleanup is what makes the double-fire harmless.
The race condition problem. A network request that is not aborted can resolve after the component has unmounted or after the dependencies have changed. The old response sets state that the new request is about to overwrite, or sets state on a component that no longer exists. The cleanup aborts the old request, so the race cannot happen.
The stale subscription problem. An effect that subscribes to a channel and re-runs when a dependency changes creates a new subscription each time. Without a cleanup, the old subscriptions accumulate. The callback fires once per subscription, and the component reacts multiple times to a single event. The cleanup unsubscribes the old subscription before the new one is created.
The exam problem. Cleanup is a standard interview question. The patterns—subscription, timer, event listener, network request—are the ones that appear in real code. Knowing which resources need cleanup and how to release them is the difference between an effect that works and an effect that leaks.
The connection problem. A WebSocket, a database connection, a file handle—each is a resource that must be closed. The cleanup closes it. An effect that opens a connection without closing it exhausts the available connections and eventually fails.
a. The cleanup function and when it runs
An effect returns a cleanup function. The function is optional. If it is returned, React stores it and calls it at the right time.
useEffect(() => {
const id = setInterval(() => {
console.log('tick');
}, 1000);
return () => {
clearInterval(id);
};
}, []);
The effect creates an interval. The cleanup clears it. React calls the cleanup when the component unmounts and before the next effect run if the dependencies change.
The cleanup runs in three situations:
| Situation | Cleanup Called |
|---|---|
| Dependencies change | Yes, before the new effect |
| Component unmounts | Yes |
| StrictMode double-invoke (development) | Yes, between the two runs |
The effect runs after the render is committed. The cleanup runs before the next effect and on unmount. The order is: render, commit, cleanup (from previous effect), effect (new).
useEffect(() => {
console.log('effect');
return () => {
console.log('cleanup');
};
}, [dep]);
When dep changes from a to b:
- The render with
dep = bcommits. - The cleanup from the
dep = aeffect runs. - The new effect with
dep = bruns.
The output is effect, then cleanup, then effect. The first effect is from the initial mount. The cleanup and the second effect are from the dependency change.
On unmount, only the cleanup runs.
useEffect(() => {
console.log('effect');
return () => {
console.log('cleanup');
};
}, []);
The output is effect on mount and cleanup on unmount.
An effect that does not return a function has no cleanup. This is correct when the effect sets nothing up.
useEffect(() => {
document.title = 'Hello';
}, []);
The effect sets the title. There is nothing to tear down. The title stays after the component unmounts, which may or may not be desired, but there is no resource to release.
An effect that sets something up without a cleanup is a bug.
// BAD: no cleanup
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
}, []);
The interval runs after the component unmounts. The setCount is called on an unmounted component. In React 18, the warning is no longer produced, but the interval still runs and wastes CPU. The cleanup clears it.
b. Common cleanup patterns
The cleanup pattern depends on the resource. Each resource has a corresponding release function.
Timer.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The setInterval returns an id. The cleanup calls clearInterval(id). The setTimeout is the same with clearTimeout.
Event listener.
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
The addEventListener and removeEventListener are paired. The handler must be the same function reference for the removal to work. A new function on each render would not be removed, which is why the handler is defined inside the effect.
Subscription.
useEffect(() => {
const subscription = source.subscribe(handleData);
return () => {
subscription.unsubscribe();
};
}, [source]);
The subscribe returns a subscription object. The cleanup calls unsubscribe. The pattern applies to any observable, event emitter, or stream.
Network request.
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setData(data))
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => {
controller.abort();
};
}, [url]);
The AbortController creates a signal. The fetch uses the signal. The cleanup aborts the request. The catch ignores the AbortError because the abort is expected.
WebSocket.
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => setMessage(event.data);
return () => {
ws.close();
};
}, [url]);
The WebSocket is created. The cleanup closes it. The close releases the connection.
Observer.
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setVisible(entry.isIntersecting);
});
if (ref.current) observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
The IntersectionObserver is created. The cleanup calls disconnect. The ResizeObserver and MutationObserver follow the same pattern with disconnect.
Document event.
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
The listener is on document. The cleanup removes it. The onClose is in the dependencies because the handler reads it.
c. The cleanup and the dependency interaction
The cleanup captures the values from the render in which the effect ran. This is the same closure behavior as the effect itself.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => {
console.log(`cleanup for ${count}`);
clearInterval(id);
};
}, [count]);
When count changes from 1 to 2:
- The cleanup from the
count = 1effect runs, loggingcleanup for 1and clearing the interval. - The new effect with
count = 2runs, creating a new interval.
The cleanup logs 1 because it captured the value from the render in which the effect ran. This is correct: the cleanup is tearing down the resource that was created for count = 1.
The cleanup should release the resource that the effect created, not the resource that the current render would create. The captured values are the ones from the effect’s render.
The dependencies determine when the cleanup runs. If the dependencies are empty, the cleanup runs only on unmount.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The interval is created once and cleared once. The setCount uses the functional update, so the effect does not depend on count.
If the dependencies include a value that changes often, the cleanup runs often.
useEffect(() => {
const id = setInterval(() => {
console.log(query);
}, 1000);
return () => clearInterval(id);
}, [query]);
Every time query changes, the old interval is cleared and a new one is created. This is correct if the interval should use the current query. It is wasteful if the interval does not need to be re-created, in which case a ref is the better pattern.
The StrictMode double-invoke in development makes the cleanup visible. An effect with a proper cleanup is harmless. An effect without a cleanup shows its bug.
// In StrictMode, this runs the effect twice
useEffect(() => {
console.log('effect');
}, []);
// Output in development: effect, effect
The effect runs, React simulates the unmount (running the cleanup, if any), and the effect runs again. Without a cleanup, the effect runs twice and the resource is duplicated.
// With cleanup
useEffect(() => {
console.log('effect');
return () => console.log('cleanup');
}, []);
// Output in development: effect, cleanup, effect
The cleanup runs between the two effects. The resource is released before the second effect creates a new one. The double-invoke is safe.
The cleanup is not called on every render. It is called when the dependencies change or the component unmounts. An effect with no dependencies and no cleanup runs after every render, but there is no cleanup to call.
Complete Example Session
// ============================================
// PART 1: TIMER WITH CLEANUP
// ============================================
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// ============================================
// PART 2: EVENT LISTENER WITH CLEANUP
// ============================================
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ============================================
// PART 3: FETCH WITH ABORT
// ============================================
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [url]);
// ============================================
// PART 4: SUBSCRIPTION WITH UNSUBSCRIBE
// ============================================
useEffect(() => {
const subscription = source.subscribe(handleData);
return () => subscription.unsubscribe();
}, [source]);
// ============================================
// PART 5: WEBSOCKET WITH CLOSE
// ============================================
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => setMessage(event.data);
return () => ws.close();
}, [url]);
// ============================================
// PART 6: INTERSECTION OBSERVER
// ============================================
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setVisible(entry.isIntersecting);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
// ============================================
// PART 7: DOCUMENT KEYDOWN
// ============================================
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// ============================================
// PART 8: CLEANUP CAPTURES VALUES
// ============================================
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => {
console.log(`cleanup for ${count}`);
clearInterval(id);
};
}, [count]);
// ============================================
// PART 9: STRICTMODE DOUBLE-INVOKE
// ============================================
useEffect(() => {
console.log('effect');
return () => console.log('cleanup');
}, []);
// Development output: effect, cleanup, effect
// ============================================
// PART 10: CLEANUP WITH REF
// ============================================
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
The ten parts covered the timer, the event listener, the fetch with abort, the subscription, the WebSocket, the observer, the document keydown, the cleanup capture, the StrictMode double-invoke, and the cleanup with a ref.
Quick Reference
Cleanup Timing
| Situation | Cleanup Runs |
|---|---|
| Dependencies change | Before the new effect |
| Component unmounts | Yes |
| StrictMode (development) | Between the two effects |
| Every render | Only if the effect re-runs |
Resource and Release
| Resource | Setup | Cleanup |
|---|---|---|
| Interval | setInterval | clearInterval |
| Timeout | setTimeout | clearTimeout |
| Event listener | addEventListener | removeEventListener |
| Subscription | subscribe | unsubscribe |
| WebSocket | new WebSocket | close |
| Fetch | fetch with signal | abort |
| Observer | new Observer | disconnect |
| Connection | open | close |
Cleanup Patterns
| Pattern | Cleanup |
|---|---|
| Timer | clearInterval(id) |
| Listener | removeEventListener(type, handler) |
| Fetch | controller.abort() |
| Subscription | subscription.unsubscribe() |
| WebSocket | ws.close() |
| Observer | observer.disconnect() |
The Cleanup and Dependencies
| Dependencies | Cleanup Runs |
|---|---|
[] | On unmount only |
[a] | On unmount and when a changes |
| Omitted | After every render |
Best Practices
✅ Do This:
// Clear timers
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []); // ✅
// Remove listeners with the same handler
useEffect(() => {
const handler = () => {};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []); // ✅
// Abort fetches
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, [url]); // ✅
// Close connections
useEffect(() => {
const ws = new WebSocket(url);
return () => ws.close();
}, [url]); // ✅
// Disconnect observers
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(ref.current);
return () => observer.disconnect();
}, []); // ✅
// Use a ref for the latest value in a stable effect
const ref = useRef(value);
useEffect(() => { ref.current = value; }, [value]); // ✅
❌ Don’t Do This:
// Don't set up a timer without clearing it
useEffect(() => {
setInterval(tick, 1000);
}, []); // ❌ leak
// Don't add a listener without removing it
useEffect(() => {
window.addEventListener('resize', handler);
}, []); // ❌ leak
// Don't remove a listener with a different function
useEffect(() => {
window.addEventListener('resize', () => {});
return () => window.removeEventListener('resize', () => {}); // ❌ different ref
}, []);
// Don't fetch without aborting
useEffect(() => {
fetch(url).then(setData);
}, [url]); // ❌ race
// Don't open a WebSocket without closing it
useEffect(() => {
const ws = new WebSocket(url);
}, [url]); // ❌ leak
// Don't forget the cleanup for a subscription
useEffect(() => {
source.subscribe(handleData);
}, [source]); // ❌ leak
// Don't use an empty array when the cleanup needs a dependency
useEffect(() => {
const id = setInterval(() => console.log(query), 1000);
return () => clearInterval(id);
}, []); // ❌ stale query
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Timer runs after unmount | No cleanup | clearInterval |
| Listener fires after unmount | No cleanup | removeEventListener |
| Memory leak | Subscription not unsubscribed | unsubscribe |
| Race condition | Fetch not aborted | AbortController |
| Double resource in StrictMode | No cleanup | Add cleanup |
| Listener not removed | Different function reference | Define handler inside effect |
| Cleanup uses stale value | Missing dependency | Add dependency |
Real-World Examples
1. Timer
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
2. Window Resize
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
3. Fetch with Abort
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => { if (err.name !== 'AbortError') console.error(err); });
return () => controller.abort();
}, [url]);
4. Keyboard Shortcut
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
5. WebSocket
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessage(e.data);
return () => ws.close();
}, [url]);
6. Interval with Ref
const ref = useRef(count);
useEffect(() => { ref.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => console.log(ref.current), 1000);
return () => clearInterval(id);
}, []);
7. Subscription
useEffect(() => {
const sub = store.subscribe(() => setState(store.getState()));
return () => sub.unsubscribe();
}, []);
8. Intersection Observer
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setVisible(entry.isIntersecting);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
9. Debounced Input
useEffect(() => {
const id = setTimeout(() => search(query), 300);
return () => clearTimeout(id);
}, [query]);
10. Online Status
useEffect(() => {
const online = () => setStatus('online');
const offline = () => setStatus('offline');
window.addEventListener('online', online);
window.addEventListener('offline', offline);
return () => {
window.removeEventListener('online', online);
window.removeEventListener('offline', offline);
};
}, []);
Visual
Effect Lifecycle with Cleanup
┌─────────────────────────────────────────────────────────────┐
│ MOUNT │
│ Render → Commit → Effect runs │
│ │
│ DEPENDENCY CHANGE │
│ Render → Commit → Cleanup (old) → Effect (new) │
│ │
│ UNMOUNT │
│ Cleanup runs │
│ │
│ The cleanup always runs before the next effect and on │
│ unmount. │
│ │
└─────────────────────────────────────────────────────────────┘
Resource Leak
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT CLEANUP │
│ │
│ Mount: interval created │
│ Unmount: component gone, interval still running │
│ Interval calls setState on unmounted component │
│ Memory leak, wasted CPU │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH CLEANUP │
│ │
│ Mount: interval created │
│ Unmount: cleanup runs, interval cleared │
│ No leak, no wasted CPU │
│ │
└─────────────────────────────────────────────────────────────┘
StrictMode Double-Invoke
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT CLEANUP (DEVELOPMENT) │
│ │
│ Effect runs → Effect runs again │
│ Two intervals, two subscriptions, two listeners │
│ The bug is exposed by the double-invoke │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH CLEANUP (DEVELOPMENT) │
│ │
│ Effect runs → Cleanup runs → Effect runs again │
│ One interval, one subscription, one listener │
│ The cleanup makes the double-invoke harmless │
│ │
└─────────────────────────────────────────────────────────────┘
Cleanup and Dependencies
┌─────────────────────────────────────────────────────────────┐
│ useEffect(() => { │
│ // setup │
│ return () => { /* cleanup */ }; │
│ }, [dep]); │
│ │
│ dep = a: │
│ setup for a │
│ │
│ dep changes to b: │
│ cleanup for a │
│ setup for b │
│ │
│ unmount: │
│ cleanup for b │
│ │
│ Each setup is paired with its cleanup. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Cleanup | Function returned from the effect |
| Runs when | Before the next effect, on unmount |
| Timer | clearInterval, clearTimeout |
| Listener | removeEventListener |
| Fetch | controller.abort() |
| Subscription | unsubscribe() |
| WebSocket | close() |
| Observer | disconnect() |
| StrictMode | Cleanup makes double-invoke safe |
| Capture | Cleanup captures the effect’s render values |
Key takeaways:
- The cleanup is the other half of the effect. The effect sets something up, and the cleanup tears it down. React calls the cleanup before the next effect run and on unmount.
- Every resource that is created needs a cleanup. Timers, event listeners, subscriptions, fetches, WebSockets, and observers are the common resources. Each has a corresponding release function.
- The cleanup prevents leaks. Without it, the resource continues after the component is gone. The interval fires, the listener fires, the subscription fires, and the component cannot be garbage-collected.
- The cleanup makes StrictMode safe. React 18 runs effects twice in development. The cleanup runs between the two runs, releasing the first resource before the second is created. An effect without a cleanup is exposed by the double-invoke.
- The cleanup captures the effect’s render values. When the dependencies change, the cleanup runs with the values from the previous render. This is correct: it is tearing down the resource that was created for those values.
- The handler must be the same reference for removal.
addEventListenerandremoveEventListenermust receive the same function. Define the handler inside the effect so the same reference is used for both. - An effect that sets nothing up needs no cleanup. Not every effect returns a function. An effect that sets a document title or logs a value does not need a cleanup because it does not create a resource.
Remember: The cleanup function is not an optional refinement. It is the mechanism that keeps the effect from leaking. Every timer, listener, subscription, request, and connection that the effect creates must be released. React calls the cleanup at the right time: before the next effect and on unmount. The StrictMode double-invoke surfaces the effects that do not clean up properly, which is why it exists. Write the cleanup as part of the effect, not as an afterthought. The effect and its cleanup are one unit: the setup and the teardown. The effect without the cleanup is a resource without a release, and the resource will outlive the component that created it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!