Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive
2.8.c — Cleanup Functions
In one sentence: Cleanup functions are how you tell React to stop synchronising — they prevent memory leaks, dangling connections, and stale state by undoing whatever the effect's setup did.
Navigation: ← Dependency Array Behaviour · Next → Data Fetching Pattern
Table of Contents
- What Is a Cleanup Function?
- When Does Cleanup Run?
- The Cleanup Timeline
- Cleanup Is a Closure Too
- Pattern 1: Event Listeners
- Pattern 2: Timers and Intervals
- Pattern 3: WebSocket Connections
- Pattern 4: AbortController for Fetch
- Pattern 5: Observers (Intersection, Resize, Mutation)
- Pattern 6: Third-Party Library Integration
- Pattern 7: Boolean Flags for Async Operations
- Memory Leaks: What Happens Without Cleanup
- The Cleanup Checklist
- Testing Cleanup Functions
- Key Takeaways
1. What Is a Cleanup Function?
A cleanup function is the function you return from your effect. It undoes whatever the setup did.
useEffect(() => {
// ─── SETUP ───────────────────────
// Start synchronising with external system
const connection = createConnection(roomId);
connection.connect();
// ─── CLEANUP ─────────────────────
// Stop synchronising with external system
return () => {
connection.disconnect();
};
}, [roomId]);
The Contract
┌─────────────────────────────────────────────┐
│ The Cleanup Contract │
│ │
│ SETUP: "I started X" │
│ CLEANUP: "I stopped X" │
│ │
│ If setup subscribes → cleanup unsubscribes │
│ If setup connects → cleanup disconnects │
│ If setup starts a timer → cleanup clears it │
│ If setup adds a listener → cleanup removes │
│ If setup creates → cleanup destroys │
│ │
│ Rule: After cleanup, it should be as if │
│ setup never ran. │
└─────────────────────────────────────────────┘
Effects That Don't Need Cleanup
Not every effect needs cleanup. If you're just reading or writing without creating an ongoing connection:
// ✅ No cleanup needed — just setting a value
useEffect(() => {
document.title = `${count} new messages`;
}, [count]);
// ✅ No cleanup needed — just logging
useEffect(() => {
analytics.logPageView(pageName);
}, [pageName]);
// ❌ NEEDS cleanup — ongoing subscription
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
2. When Does Cleanup Run?
Cleanup runs at exactly two moments:
Moment 1: Before Re-running the Effect (Dependency Changed)
roomId changes from "general" to "random"
│
▼
React re-renders component
│
▼
Browser paints
│
▼
Cleanup from PREVIOUS effect runs ← disconnects "general"
│
▼
New effect setup runs ← connects to "random"
Moment 2: When the Component Unmounts
Component is removed from the DOM
│
▼
Cleanup from LAST effect runs ← disconnects whatever room was last
Visual Timeline
Mount (roomId = "general")
│
├─ Render → Paint → Setup: connect("general")
│
▼
Update (roomId = "random")
│
├─ Render → Paint → Cleanup: disconnect("general")
│ → Setup: connect("random")
│
▼
Update (roomId = "random") ← same value
│
├─ Render → Paint → [No cleanup, no setup — deps unchanged]
│
▼
Unmount
│
└─ Cleanup: disconnect("random")
3. The Cleanup Timeline
Understanding the exact order of operations:
function ChatRoom({ roomId }) {
useEffect(() => {
console.log(`[${roomId}] Setup: connecting`);
return () => {
console.log(`[${roomId}] Cleanup: disconnecting`);
};
}, [roomId]);
console.log(`[${roomId}] Render`);
}
What Logs When
// Mount with roomId="general"
[general] Render
[general] Setup: connecting
// roomId changes to "random"
[random] Render
[general] Cleanup: disconnecting ← OLD roomId's cleanup
[random] Setup: connecting ← NEW roomId's setup
// roomId changes to "music"
[music] Render
[random] Cleanup: disconnecting
[music] Setup: connecting
// Unmount
[music] Cleanup: disconnecting
Multiple Effects — Cleanup Order
function App() {
useEffect(() => {
console.log('Effect A: setup');
return () => console.log('Effect A: cleanup');
}, [dep]);
useEffect(() => {
console.log('Effect B: setup');
return () => console.log('Effect B: cleanup');
}, [dep]);
}
// When dep changes:
// Effect A: cleanup ← All cleanups first (in order)
// Effect B: cleanup
// Effect A: setup ← Then all setups (in order)
// Effect B: setup
4. Cleanup Is a Closure Too
The cleanup function closes over the values from the render when it was created:
function Logger({ userId }) {
useEffect(() => {
console.log(`Setup for user: ${userId}`);
return () => {
// This cleanup "remembers" the userId from when it was created
console.log(`Cleanup for user: ${userId}`);
};
}, [userId]);
}
// userId changes: 1 → 2 → 3 → unmount
// "Setup for user: 1"
// "Cleanup for user: 1" ← NOT "Cleanup for user: 2"!
// "Setup for user: 2"
// "Cleanup for user: 2"
// "Setup for user: 3"
// "Cleanup for user: 3" ← On unmount
This is important: cleanup always has access to the OLD values, not the current ones. This is correct behaviour — you're cleaning up the previous synchronisation.
┌─────────────────────────────────────────┐
│ Render 1 (userId = 1) │
│ setup: connects for user 1 │
│ cleanup: disconnects for user 1 │ ← Cleanup "remembers" 1
├─────────────────────────────────────────┤
│ Render 2 (userId = 2) │
│ React runs Render 1's cleanup first │
│ setup: connects for user 2 │
│ cleanup: disconnects for user 2 │ ← Cleanup "remembers" 2
├─────────────────────────────────────────┤
│ Unmount │
│ React runs Render 2's cleanup │
└─────────────────────────────────────────┘
5. Pattern 1: Event Listeners
The most common cleanup pattern:
// ── WINDOW/DOCUMENT EVENTS ──────────────────────
function useWindowSize() {
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 size;
}
// ── KEYBOARD SHORTCUTS ──────────────────────────
function useKeyboardShortcut(key, callback) {
useEffect(() => {
function handleKeyDown(event) {
if (event.key === key) {
event.preventDefault();
callback();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [key, callback]);
}
// ── SCROLL POSITION ─────────────────────────────
function useScrollPosition() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
function handleScroll() {
setScrollY(window.scrollY);
}
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return scrollY;
}
// ── MEDIA QUERY ─────────────────────────────────
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
function handleChange(event) {
setMatches(event.matches);
}
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
Common Mistake: Wrong Function Reference
// ❌ BUG: Can't remove listener — different function!
useEffect(() => {
window.addEventListener('resize', () => {
setWidth(window.innerWidth); // Anonymous function
});
return () => {
window.removeEventListener('resize', () => {
setWidth(window.innerWidth); // DIFFERENT anonymous function!
});
};
}, []);
// ✅ CORRECT: Same function reference
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
// Same `handleResize` reference ✓
}, []);
6. Pattern 2: Timers and Intervals
// ── SIMPLE TIMEOUT ──────────────────────────────
function DelayedMessage({ text, delay }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setVisible(true);
}, delay);
return () => clearTimeout(timeoutId);
// If delay or text changes before timeout fires,
// the old timeout is cleared and a new one starts
}, [delay]);
}
// ── INTERVAL COUNTER ────────────────────────────
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(s => s + 1); // Updater function — no stale closure
}, 1000);
return () => clearInterval(intervalId);
}, []); // Empty deps — one interval for the lifetime
return <span>{seconds}s</span>;
}
// ── DEBOUNCED VALUE ─────────────────────────────
function useDebouncedValue(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timeoutId);
// Each keystroke: clear previous timeout, start new one
// Only fires after user stops typing for `delay` ms
}, [value, delay]);
return debouncedValue;
}
// ── POLLING ─────────────────────────────────────
function usePolling(fetchFn, interval) {
const [data, setData] = useState(null);
useEffect(() => {
let active = true;
async function poll() {
try {
const result = await fetchFn();
if (active) setData(result);
} catch (err) {
console.error('Polling error:', err);
}
}
poll(); // Fetch immediately
const id = setInterval(poll, interval);
return () => {
active = false;
clearInterval(id);
};
}, [fetchFn, interval]);
return data;
}
7. Pattern 3: WebSocket Connections
// ── BASIC WEBSOCKET ─────────────────────────────
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('connecting');
useEffect(() => {
const ws = new WebSocket(`wss://chat.app/rooms/${roomId}`);
ws.onopen = () => {
setStatus('connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onerror = () => {
setStatus('error');
};
ws.onclose = () => {
setStatus('disconnected');
};
return () => {
ws.close();
};
}, [roomId]);
return { messages, status };
}
// ── WEBSOCKET WITH RECONNECTION ─────────────────
function useWebSocket(url) {
const [status, setStatus] = useState('disconnected');
const [lastMessage, setLastMessage] = useState(null);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const retriesRef = useRef(0);
useEffect(() => {
let cancelled = false;
function connect() {
if (cancelled) return;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (cancelled) { ws.close(); return; }
setStatus('connected');
retriesRef.current = 0;
};
ws.onmessage = (event) => {
if (cancelled) return;
setLastMessage(JSON.parse(event.data));
};
ws.onclose = () => {
if (cancelled) return;
setStatus('reconnecting');
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
retriesRef.current += 1;
reconnectTimeoutRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close(); // Will trigger onclose → reconnect
};
}
connect();
return () => {
cancelled = true;
clearTimeout(reconnectTimeoutRef.current);
if (wsRef.current) {
wsRef.current.close();
}
};
}, [url]);
const send = useCallback((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
return { status, lastMessage, send };
}
8. Pattern 4: AbortController for Fetch
// ── BASIC ABORT ─────────────────────────────────
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
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);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return; // Ignore abort
setError(err.message);
setLoading(false);
});
return () => controller.abort();
}, [userId]);
return { user, loading, error };
}
// ── ABORT WITH TIMEOUT ──────────────────────────
function useFetchWithTimeout(url, timeout = 5000) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name === 'AbortError') {
setState(prev => ({ ...prev, loading: false, error: 'Request timed out' }));
} else {
setState({ data: null, loading: false, error: err.message });
}
})
.finally(() => clearTimeout(timeoutId));
return () => {
controller.abort();
clearTimeout(timeoutId);
};
}, [url, timeout]);
return state;
}
// ── ABORT MULTIPLE PARALLEL REQUESTS ────────────
function useDashboardData(dashboardId) {
const [data, setData] = useState({
metrics: null,
chart: null,
alerts: null,
});
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
Promise.all([
fetch(`/api/metrics/${dashboardId}`, { signal }).then(r => r.json()),
fetch(`/api/chart/${dashboardId}`, { signal }).then(r => r.json()),
fetch(`/api/alerts/${dashboardId}`, { signal }).then(r => r.json()),
])
.then(([metrics, chart, alerts]) => {
setData({ metrics, chart, alerts });
})
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort(); // Cancels ALL three requests
}, [dashboardId]);
return data;
}
9. Pattern 5: Observers
Intersection Observer
function useIntersectionObserver(ref, options = {}) {
const [entry, setEntry] = useState(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{
threshold: 0.1,
...options,
}
);
observer.observe(element);
return () => {
observer.unobserve(element);
observer.disconnect();
};
}, [ref, options.threshold, options.rootMargin]);
return entry;
}
// Usage: Lazy loading images
function LazyImage({ src, alt }) {
const imgRef = useRef(null);
const entry = useIntersectionObserver(imgRef);
const isVisible = entry?.isIntersecting;
return (
<img
ref={imgRef}
src={isVisible ? src : undefined}
alt={alt}
style={{ minHeight: 200 }}
/>
);
}
Resize Observer
function useResizeObserver(ref) {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setDimensions({ width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return dimensions;
}
// Usage
function ResponsiveChart({ data }) {
const containerRef = useRef(null);
const { width, height } = useResizeObserver(containerRef);
return (
<div ref={containerRef} style={{ width: '100%', height: 400 }}>
<Chart data={data} width={width} height={height} />
</div>
);
}
Mutation Observer
function useMutationObserver(ref, callback, options = {}) {
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new MutationObserver(callback);
observer.observe(element, {
childList: true,
subtree: true,
attributes: true,
...options,
});
return () => observer.disconnect();
}, [ref, callback]);
}
10. Pattern 6: Third-Party Library Integration
// ── CHART.JS ────────────────────────────────────
function BarChart({ data, options }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
chartRef.current = new Chart(ctx, {
type: 'bar',
data: data,
options: options,
});
return () => {
chartRef.current.destroy(); // Chart.js requires explicit destroy
chartRef.current = null;
};
}, [data, options]);
return <canvas ref={canvasRef} />;
}
// ── MAP LIBRARY (Mapbox/Leaflet) ────────────────
function MapView({ center, zoom, markers }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
// Initialise map once
useEffect(() => {
const map = new mapboxgl.Map({
container: containerRef.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: center,
zoom: zoom,
});
mapRef.current = map;
return () => {
map.remove(); // Clean up map instance
mapRef.current = null;
};
}, []); // Mount once
// Sync markers separately
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const markerInstances = markers.map(m => {
return new mapboxgl.Marker()
.setLngLat(m.position)
.addTo(map);
});
return () => {
markerInstances.forEach(m => m.remove());
};
}, [markers]);
return <div ref={containerRef} style={{ width: '100%', height: 400 }} />;
}
// ── ANIMATION LIBRARY (GSAP) ────────────────────
function AnimatedElement({ children }) {
const elementRef = useRef(null);
useEffect(() => {
const tween = gsap.to(elementRef.current, {
opacity: 1,
y: 0,
duration: 0.5,
});
return () => {
tween.kill(); // Stop and clean up animation
};
}, []);
return (
<div ref={elementRef} style={{ opacity: 0, transform: 'translateY(20px)' }}>
{children}
</div>
);
}
11. Pattern 7: Boolean Flags for Async Operations
When you can't use AbortController (or need simpler logic):
// ── CANCELLED FLAG ──────────────────────────────
function UserData({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // Flag
async function loadUser() {
const data = await fetchUser(userId);
// Only update state if this effect is still "active"
if (!cancelled) {
setUser(data);
}
}
loadUser();
return () => {
cancelled = true; // Mark as stale
};
}, [userId]);
}
// ── WHY THIS WORKS ──────────────────────────────
//
// userId: 1 → fetch starts → userId changes to 2 →
// cleanup sets cancelled=true for userId:1's closure →
// userId:1 fetch resolves → cancelled is true → setUser skipped ✓
// userId:2 fetch starts fresh with cancelled=false →
// userId:2 fetch resolves → cancelled is false → setUser runs ✓
When to Use Which
| Approach | Use When | Advantages |
|---|---|---|
| AbortController | fetch() or XMLHttpRequest | Actually cancels the network request |
| Boolean flag | Any async operation (can't cancel) | Simple, works with any Promise |
| Both together | Production fetch operations | Maximum correctness |
// ── COMBINING BOTH ──────────────────────────────
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
if (!cancelled) {
setData(data);
}
} catch (err) {
if (!cancelled && err.name !== 'AbortError') {
setError(err.message);
}
}
}
loadData();
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
12. Memory Leaks: What Happens Without Cleanup
What Is a Memory Leak?
A memory leak occurs when your component is gone but something still references it:
Component mounts
└─ Sets up event listener on window
└─ Starts WebSocket connection
└─ Creates interval timer
Component unmounts (WITHOUT cleanup)
└─ Event listener still fires → tries to call setState on unmounted component
└─ WebSocket still receives messages → tries to update state
└─ Interval still ticking → tries to update state
└─ Browser warns: "Can't perform a React state update on an unmounted component"
Real Memory Leak Example
// ❌ MEMORY LEAK
function StockTicker({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
const ws = new WebSocket(`wss://stocks.api/${symbol}`);
ws.onmessage = (e) => setPrice(JSON.parse(e.data).price);
// No cleanup! WebSocket stays open after unmount.
// If user navigates away and back 10 times, 10 WebSockets open!
}, [symbol]);
}
// ✅ NO LEAK
function StockTicker({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
const ws = new WebSocket(`wss://stocks.api/${symbol}`);
ws.onmessage = (e) => setPrice(JSON.parse(e.data).price);
return () => ws.close(); // ← This one line prevents the leak
}, [symbol]);
}
Leak Severity by Type
| Leak Type | Severity | Symptoms |
|---|---|---|
| Event listener on window | 🟡 Medium | Multiple handlers fire, memory grows |
| WebSocket/SSE connection | 🔴 High | Server resources consumed, network active |
| setInterval | 🟡 Medium | CPU usage, console warnings |
| fetch without abort | 🟢 Low | Response ignored, minor memory |
| Third-party library | 🔴 High | Depends on library (map, chart, video) |
How to Detect Memory Leaks
1. React DevTools:
- Look for "setState on unmounted component" warnings
2. Browser DevTools → Memory tab:
- Take heap snapshot before/after navigating
- Compare retained objects
3. Network tab:
- Look for connections that stay open after leaving a page
4. Performance tab:
- Look for event listeners that keep accumulating
5. Strict Mode:
- Double mount-unmount-remount reveals missing cleanups
13. The Cleanup Checklist
For Every Effect, Ask:
□ Does this effect create a subscription?
→ Add cleanup that unsubscribes
□ Does this effect add an event listener?
→ Add cleanup that removes the listener (same function reference!)
□ Does this effect start a timer/interval?
→ Add cleanup that clears it
□ Does this effect open a connection?
→ Add cleanup that closes it
□ Does this effect create a DOM element?
→ Add cleanup that removes it
□ Does this effect initialise a library?
→ Add cleanup that destroys/disposes it
□ Does this effect start an animation?
→ Add cleanup that stops/kills it
□ Does this effect make a fetch request?
→ Add cleanup with AbortController
□ Does this effect set an async operation in flight?
→ Add cleanup with a boolean flag
Quick Reference Table
| Setup Action | Cleanup Action |
|---|---|
addEventListener(type, fn) | removeEventListener(type, fn) |
setInterval(fn, ms) | clearInterval(id) |
setTimeout(fn, ms) | clearTimeout(id) |
new WebSocket(url) | ws.close() |
new IntersectionObserver(fn) | observer.disconnect() |
new ResizeObserver(fn) | observer.disconnect() |
new MutationObserver(fn) | observer.disconnect() |
new Chart(ctx, config) | chart.destroy() |
map.addTo(container) | map.remove() |
fetch(url, { signal }) | controller.abort() |
element.focus() | (usually no cleanup) |
document.title = x | (usually no cleanup) |
gsap.to(el, config) | tween.kill() |
14. Testing Cleanup Functions
Testing Strategy
// Component
function Timer({ onTick }) {
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, [onTick]);
return null;
}
// Test: Cleanup runs on unmount
import { render, act } from '@testing-library/react';
test('clears interval on unmount', () => {
jest.useFakeTimers();
const onTick = jest.fn();
const { unmount } = render(<Timer onTick={onTick} />);
// Fast-forward 3 seconds
act(() => jest.advanceTimersByTime(3000));
expect(onTick).toHaveBeenCalledTimes(3);
// Unmount component
unmount();
// Fast-forward more — onTick should NOT be called
act(() => jest.advanceTimersByTime(3000));
expect(onTick).toHaveBeenCalledTimes(3); // Still 3, not 6
jest.useRealTimers();
});
// Test: Cleanup runs on dependency change
test('reconnects when roomId changes', () => {
const mockClose = jest.fn();
const mockConnect = jest.fn(() => ({ close: mockClose }));
const { rerender } = render(<ChatRoom roomId="general" connect={mockConnect} />);
expect(mockConnect).toHaveBeenCalledWith('general');
rerender(<ChatRoom roomId="random" connect={mockConnect} />);
expect(mockClose).toHaveBeenCalled(); // Cleaned up "general"
expect(mockConnect).toHaveBeenCalledWith('random'); // Connected to "random"
});
15. Key Takeaways
-
Cleanup = stop synchronising. If setup starts something, cleanup stops it. After cleanup, it's as if setup never ran.
-
Cleanup runs twice: before re-running the effect (dep change), and on unmount. Both use the OLD render's values.
-
Cleanup is a closure. It captures the values from the render when it was created, not the current render's values.
-
Every subscription needs cleanup. Event listeners, WebSockets, timers, observers, third-party libraries — all need to be torn down.
-
Same function reference for addEventListener/removeEventListener. Define the handler as a named function, not an inline arrow.
-
AbortController for fetch. It actually cancels the network request. Boolean flags only prevent state updates.
-
Memory leaks are cumulative. One missed cleanup won't crash your app. But if users navigate back and forth, leaked connections/listeners pile up.
-
Strict Mode is your friend. It runs setup → cleanup → setup to catch missing cleanups early.
Explain-It Challenge
-
The Hotel Analogy: Explain cleanup functions using a hotel check-in/check-out analogy. When you check out (cleanup), what happens to your room? When the hotel changes your room (dep change), what's the sequence?
-
The Stale Cleanup: Your teammate is confused: "Why does my cleanup function log the OLD roomId instead of the current one?" Explain using the closure mental model and why this is actually correct behaviour.
-
Leak Detective: You join a project where users complain it gets slower the longer they use the app. How would you systematically find and fix memory leaks caused by missing cleanup functions?
Navigation: ← Dependency Array Behaviour · Next → Data Fetching Pattern