Every useEffect that sets up something ongoing — an HTTP request, a timer, an event listener, a WebSocket connection — must tear it down when it is no longer needed. Without teardown, components that are removed from the screen leave dangling connections and, worse, cause state updates on components that no longer exist. The cleanup function is how React provides this teardown mechanism — and understanding exactly when React calls it, and what it must undo, is what makes the difference between a robust MERN application and one that slowly leaks memory or crashes under navigation.
When React Calls the Cleanup Function
useEffect returns a cleanup function:
useEffect(() => {
// setup
return () => { /* cleanup */ };
}, [dep]);
React calls the cleanup function:
1. Before running the effect AGAIN (when dep changes)
→ cleanup previous effect → run new effect
2. When the component UNMOUNTS
→ cleanup to prevent memory leaks and stale updates
React does NOT call cleanup:
- Between renders where dep did NOT change
- Before the very first run of the effect
CanceledError (or axios.isCancel(err) returns true) when the signal is aborted — check for this and return early to avoid updating state for a cancelled request.setPosts([]) would clear the posts state when the user navigates away, causing a flash of empty content if the user returns. Clean up the connection or subscription, not the state.Cancelling API Requests with AbortController
function PostDetailPage({ postId }) {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Create an AbortController for this effect run
const controller = new AbortController();
const fetchPost = async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get(`/api/posts/${postId}`, {
signal: controller.signal, // pass the abort signal to Axios
});
setPost(res.data.data);
} catch (err) {
if (axios.isCancel(err)) {
// Request was cancelled by cleanup — do nothing
return;
}
setError(err.response?.data?.message || 'Failed to load post');
} finally {
setLoading(false);
}
};
fetchPost();
// Cleanup: abort the in-flight request when:
// 1. postId changes (new request is starting)
// 2. Component unmounts (user navigated away)
return () => controller.abort();
}, [postId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <article><h1>{post?.title}</h1></article>;
}
Clearing Timers
// ── setInterval — must be cleared on cleanup ──────────────────────────────────
function LiveClock() {
const [time, setTime] = useState(new Date().toLocaleTimeString());
useEffect(() => {
const intervalId = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
// Cleanup: stop the interval when component unmounts
return () => clearInterval(intervalId);
}, []); // setup once on mount
return <span className="clock">{time}</span>;
}
// ── setTimeout — useful for debouncing ────────────────────────────────────────
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
useEffect(() => {
// Wait 400ms after the last keystroke before searching
const timeoutId = setTimeout(() => {
if (query.trim()) onSearch(query);
}, 400);
// Cleanup: cancel the pending timeout when query changes again
// (user typed another character before 400ms elapsed)
return () => clearTimeout(timeoutId);
}, [query, onSearch]);
return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />;
}
Removing Event Listeners
// ── Window event listener — add on mount, remove on unmount ───────────────────
function KeyboardShortcuts({ onNewPost }) {
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
onNewPost();
}
};
window.addEventListener('keydown', handleKeyDown);
// Cleanup: remove the listener to prevent duplicates and memory leaks
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onNewPost]); // re-register if onNewPost changes
return null;
}
// ── Scroll position tracking ──────────────────────────────────────────────────
function ScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const pct = (scrollTop / (scrollHeight - clientHeight)) * 100;
setProgress(Math.round(pct));
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div className="scroll-progress" style={{ width: `${progress}%` }} />;
}
WebSocket Connection Cleanup
function LiveComments({ postId }) {
const [comments, setComments] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://api.stacklesson.com/ws/posts/${postId}/comments`);
ws.onopen = () => console.log('Connected to live comments');
ws.onmessage = (e) => setComments(prev => [...prev, JSON.parse(e.data)]);
ws.onerror = (err) => console.error('WebSocket error:', err);
// Cleanup: close the WebSocket when component unmounts or postId changes
return () => {
ws.close();
console.log('WebSocket closed');
};
}, [postId]);
return (
<ul>{comments.map(c => <li key={c._id}>{c.body}</li>)}</ul>
);
}
Common Mistakes
Mistake 1 — Not cancelling Axios requests on component unmount
❌ Wrong — Axios callback calls setState after unmount:
useEffect(() => {
axios.get('/api/posts').then(res => setPosts(res.data.data));
// If component unmounts before response arrives:
// setPosts is called on unmounted component → warning
}, []);
✅ Correct — cancel the request in cleanup:
useEffect(() => {
const controller = new AbortController();
axios.get('/api/posts', { signal: controller.signal })
.then(res => setPosts(res.data.data))
.catch(err => { if (!axios.isCancel(err)) setError(err.message); });
return () => controller.abort(); // ✓
}, []);
Mistake 2 — Not clearing a setInterval
❌ Wrong — interval continues running after unmount:
useEffect(() => {
setInterval(() => setTime(new Date()), 1000);
// No cleanup — interval fires every second forever, even after unmount
}, []);
✅ Correct — always return clearInterval:
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id); // ✓
}, []);
Mistake 3 — Calling setState in cleanup
❌ Wrong — clearing data in cleanup causes a flash when navigating back:
return () => {
setPosts([]); // clears data — user navigates back and sees empty list briefly
setLoading(true); // sets loading on unmounted component
};
✅ Correct — cleanup only undoes what the effect set up (listeners, requests, timers).
Quick Reference
| What to Clean Up | How |
|---|---|
| Axios/fetch request | AbortController + controller.abort() |
| setInterval | clearInterval(id) |
| setTimeout | clearTimeout(id) |
| window event listener | window.removeEventListener(event, handler) |
| WebSocket | ws.close() |
| Third-party subscription | Call the unsubscribe function returned by the library |