Mesrai
Back to blog
// essayTechnical Deep Dive

Stale useEffect Dependency: The Closure Bug Mesrai Catches Daily

Real PR catch: useEffect with empty deps + setResults([...results, r]) — stale state + listener leak. Functional update + cleanup fix.

Mesrai TeamAugust 5, 20268 min read

A search box subscribing to websocket results. Two bugs in five lines: the dep array captures a stale `results`, and the subscription is never cleaned up. Mesrai's review covered both.

The vulnerable diff

tsxcomponents/SearchBox.tsx
// components/SearchBox.tsx
export function SearchBox() {
  const [results, setResults] = useState<Result[]>([]);
  useEffect(() => {
    // BUG #1: results captured at mount (empty), used as previous value
    // BUG #2: socket.on never unsubscribed
    socket.on("results", (r) => setResults([...results, r]));
  }, []);
  return <List items={results} />;
}

What is wrong

Two related bugs. First: the callback closes over `results` from the initial render — value `[]` — so every message overwrites with a single-element array. The fix is the functional update form: `setResults(prev => [...prev, r])`, which reads the latest state from React's queue. Second: `socket.on` adds a listener; without a paired `socket.off` in the effect's cleanup function, every mount/unmount cycle leaks a listener. After a few navigation cycles the same callback fires N times per message.

The attack

Symptom: search results show only the last result. Heap profile shows growing listener count.

textexploit
# Mount the component, send 3 events, unmount, mount again, send 3.
# Expected: 3 results after final flow.
# Actual: 6 messages handled (listeners stacked); 1 result shown.

Both bugs are silent at code review.

Mesrai's review comment

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

[mesrai] [code-review] [Language] [React] [medium]

useEffect has two issues:

  1. `results` captured at mount = []. Each handler call overwrites
     state because [...results, r] uses the stale empty array.
     Use the functional update form.

  2. socket.on adds a listener without paired cleanup. Mount/unmount
     cycles leak listeners; messages fire N times after N mounts.

Fix both:

  useEffect(() => {
    const onResults = (r: Result) => setResults(prev => [...prev, r]);
    socket.on("results", onResults);
    return () => socket.off("results", onResults);
  }, []);

Always pair subscribe + unsubscribe in effect + cleanup.

The fix

tsxcomponents/SearchBox.tsx (fixed)
// components/SearchBox.tsx — fixed
export function SearchBox() {
  const [results, setResults] = useState<Result[]>([]);
  useEffect(() => {
    const onResults = (r: Result) => setResults(prev => [...prev, r]);
    socket.on("results", onResults);
    return () => socket.off("results", onResults);
  }, []);
  return <List items={results} />;
}

Functional update form for the state. Named handler so cleanup can reference the same function. Cleanup unsubscribes on every unmount or dep change. Both bugs closed in three lines.

Why human review missed it

Empty-deps + state-read is one of React's most common subtle bugs. The lint rule (`react-hooks/exhaustive-deps`) catches some shapes but is often disabled for cases where the dev intended an empty deps. Mesrai's rule pack catches the specific shape of state-read inside an event handler with empty deps, and missing-cleanup on subscribe-shape calls.

Related rules + further reading

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

React docs: Synchronizing with Effects.

Top React bug class by frequency.

Takeaway

Functional update for state. Cleanup for subscriptions. Mesrai catches both shapes on every effect.

// try

See it on your next PR.

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