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
// 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.
# 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
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
// 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.