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

  1. What Is a Cleanup Function?
  2. When Does Cleanup Run?
  3. The Cleanup Timeline
  4. Cleanup Is a Closure Too
  5. Pattern 1: Event Listeners
  6. Pattern 2: Timers and Intervals
  7. Pattern 3: WebSocket Connections
  8. Pattern 4: AbortController for Fetch
  9. Pattern 5: Observers (Intersection, Resize, Mutation)
  10. Pattern 6: Third-Party Library Integration
  11. Pattern 7: Boolean Flags for Async Operations
  12. Memory Leaks: What Happens Without Cleanup
  13. The Cleanup Checklist
  14. Testing Cleanup Functions
  15. 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

ApproachUse WhenAdvantages
AbortControllerfetch() or XMLHttpRequestActually cancels the network request
Boolean flagAny async operation (can't cancel)Simple, works with any Promise
Both togetherProduction fetch operationsMaximum 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 TypeSeveritySymptoms
Event listener on window🟡 MediumMultiple handlers fire, memory grows
WebSocket/SSE connection🔴 HighServer resources consumed, network active
setInterval🟡 MediumCPU usage, console warnings
fetch without abort🟢 LowResponse ignored, minor memory
Third-party library🔴 HighDepends 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 ActionCleanup 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

  1. Cleanup = stop synchronising. If setup starts something, cleanup stops it. After cleanup, it's as if setup never ran.

  2. Cleanup runs twice: before re-running the effect (dep change), and on unmount. Both use the OLD render's values.

  3. Cleanup is a closure. It captures the values from the render when it was created, not the current render's values.

  4. Every subscription needs cleanup. Event listeners, WebSockets, timers, observers, third-party libraries — all need to be torn down.

  5. Same function reference for addEventListener/removeEventListener. Define the handler as a named function, not an inline arrow.

  6. AbortController for fetch. It actually cancels the network request. Boolean flags only prevent state updates.

  7. Memory leaks are cumulative. One missed cleanup won't crash your app. But if users navigate back and forth, leaked connections/listeners pile up.

  8. Strict Mode is your friend. It runs setup → cleanup → setup to catch missing cleanups early.


Explain-It Challenge

  1. 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?

  2. 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.

  3. 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