Mesrai
Back to blog
// essayTechnical Deep Dive

useEffect Cleanup Never Runs: The Subscribed Listener Leak

Real PR catch: `window.addEventListener` in useEffect with no cleanup — listener stacks per remount. Cleanup fix.

Mesrai TeamAugust 8, 20268 min read

A scroll-tracking hook adding a `scroll` listener in useEffect. No cleanup. Each mount stacks a listener; unmount leaves the old one behind. After 5 mount/unmount cycles, the callback fires 5× per scroll event.

The vulnerable diff

tshooks/useScroll.ts
// hooks/useScroll.ts
export function useScroll(onScroll: (y: number) => void) {
  useEffect(() => {
    // BUG: no cleanup — listener accumulates on every mount
    window.addEventListener("scroll", () => onScroll(window.scrollY));
  }, []);
}

What is wrong

React effects are mount + cleanup pairs by design. Every subscription you add inside the effect must be matched by a removal in the cleanup function. Without it, mount/unmount cycles (route navigation, conditional render, hot reload) stack subscriptions. After N cycles the callback runs N times per event. Memory grows because each handler is a closure holding a reference to props from its mount cycle.

The attack

Reproducer: log in the handler, navigate between pages 5 times:

textexploit
# After 5 navigations:
[1] scroll y=200
[2] scroll y=200
[3] scroll y=200
[4] scroll y=200
[5] scroll y=200  // 5 callbacks per scroll event

If the callback does any meaningful work — analytics ping, layout calc — the cost grows linearly.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Performance] [React-Leak] [medium]

`window.addEventListener` inside useEffect without paired remove.
Listener leaks per mount/unmount cycle.

Always return a cleanup function:

  useEffect(() => {
    const handler = () => onScroll(window.scrollY);
    window.addEventListener("scroll", handler);
    return () => window.removeEventListener("scroll", handler);
  }, [onScroll]);

Named handler so the cleanup can reference the same function (you
cannot remove an inline arrow). Include `onScroll` in deps if the
callback can change.

The fix

tshooks/useScroll.ts (fixed)
// hooks/useScroll.ts — fixed
export function useScroll(onScroll: (y: number) => void) {
  useEffect(() => {
    const handler = () => onScroll(window.scrollY);
    window.addEventListener("scroll", handler);
    return () => window.removeEventListener("scroll", handler);
  }, [onScroll]);
}

Named handler, paired add/remove, dep on the callback so React re-subscribes when the callback identity changes. Standard React subscribe/cleanup pattern.

Why human review missed it

Cleanup is the canonical React effect mistake — the mount side is obvious, the cleanup side is the discipline. ESLint's `react-hooks/exhaustive-deps` partially helps. Mesrai catches every `addEventListener`, `setInterval`, `subscribe()` call in a useEffect without a paired cleanup.

Related rules + further reading

Mesrai rule pack: language/react-effect-cleanup — flags subscribe-shape calls in useEffect without cleanup.

React docs: Synchronizing with Effects § You Might Not Need an Effect.

Most common React memory-leak class.

Takeaway

Every subscription has a cleanup. Mount adds, cleanup removes. Mesrai catches every gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.