The Cleanup Function — Preventing Memory Leaks

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
Note: In React 18 StrictMode (development only), effects run twice on mount — React mounts, runs setup, then immediately runs cleanup and setup again. This is designed to detect cleanup bugs: if your cleanup properly undoes the setup, the second run should produce identical results to the first. If it does not, you have a cleanup bug. This double-invocation only happens in development.
Tip: The AbortController API is the modern way to cancel fetch and Axios requests. Create a controller at the start of the effect, pass its signal to the request, and abort the controller in the cleanup function. Axios will throw an 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.
Warning: Cleanup functions should only undo what the effect set up — they should not perform new side effects or state updates. A cleanup function that calls 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

🧠 Test Yourself

A user opens the PostDetailPage which starts an Axios request. Before the request completes they navigate away, unmounting the component. The response arrives and the callback calls setPost(res.data). What happens and how do you prevent it?