5 min read
0%

Cleanup Function Timing in useEffect

Back to Blog
Cleanup Function Timing in useEffect

Cleanup Function Timing in useEffect

Effect cleanup runs before the next effect execution and before unmount. Getting the timing right prevents memory leaks, stale subscriptions, and double-execution bugs.

Cleanup Runs Before Next Effect

useEffect(() => {
  const sub = subscribe(channel);
  console.log('subscribed');

  return () => {
    sub.unsubscribe();
    console.log('unsubscribed');
  };
}, [channel]);

When channel changes:

  1. Cleanup of previous effect runs: unsubscribed
  2. New effect runs: subscribed

React guarantees cleanup runs before the next effect for the same hook. The new effect never runs while the old subscription is still active.

Cleanup on Unmount

useEffect(() => {
  const timer = setInterval(() => tick(), 1000);
  return () => clearInterval(timer); // runs when component unmounts
}, []);

Without the cleanup, the interval keeps firing after unmount, calling tick() (which likely calls setState) on a dead component.

StrictMode Double-Run

React StrictMode (dev only) mounts → unmounts → remounts every component, exercising the cleanup path:

useEffect(() => {
  console.log('mount');
  return () => console.log('cleanup');
}, []);

// Dev output:
// mount
// cleanup  ← StrictMode simulated unmount
// mount    ← actual second mount

If the second mount breaks something, your cleanup is incomplete.

Async Effects

Effects can’t return Promises — only a cleanup function or nothing:

// Wrong: async effects can't return cleanup
useEffect(async () => {
  const data = await fetch('/api');
  setData(data);
}, []);

// Right: async inside, sync cleanup
useEffect(() => {
  let cancelled = false;

  async function load() {
    const data = await fetchData();
    if (!cancelled) setData(data); // guard against stale update
  }

  load();
  return () => { cancelled = true; };
}, []);

Or use AbortController for fetch:

useEffect(() => {
  const controller = new AbortController();
  fetch('/api', { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => { if (err.name !== 'AbortError') setError(err); });
  return () => controller.abort();
}, []);

Cleanup Reference Table

ResourceCleanup
setIntervalclearInterval
setTimeoutclearTimeout
addEventListenerremoveEventListener
WebSocketws.close()
Observablesub.unsubscribe()
FetchAbortController.abort()
MutationObserverobserver.disconnect()
IntersectionObserverobserver.disconnect()

Canvas is not supported in your browser