A counter component using `setInterval` inside `useEffect`. Looks like every React-state tutorial. Behaves wrong: the count stays stuck at 1 forever. Classic stale-closure bug.
The vulnerable diff
// components/Counter.tsx
import { useState, useEffect } from "react";
export function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// BUG: count captured at mount (value 0). Increments to 1, then stuck.
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}What is wrong
React's `useEffect` with an empty dependency array runs once at mount. The `setInterval` callback closes over the `count` variable from that initial render — value 0. Each tick computes `setCount(0 + 1) = 1`. The state updates to 1, the component re-renders, but the interval is still running with the old closure that still sees `count = 0`. Result: count stuck at 1 forever. The fix is either to use the functional update form (which reads the latest state without closing over it) or to include `count` in the dep array (which re-creates the interval each tick, usually wrong for `setInterval`).
The attack
Symptom: counter increments to 1, then sits forever. Console.log in the callback confirms — `count` is always 0 in the printed value.
[render] count = 0
[interval] sees count=0, calls setCount(1)
[render] count = 1
[interval] STILL sees count=0 (stale closure), calls setCount(1)
[render] count = 1 (no change, no re-render)
[interval] STILL sees count=0, calls setCount(1)
...stuck.Same pattern across every async closure in React: setTimeout, fetch handlers, event subscriptions all suffer if they depend on state without using the functional form or proper deps.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Language] [React-Stale-Closure] [medium]
`setInterval` callback closes over `count` from the initial render
(empty deps). Each tick reads count=0 forever. Counter stuck at 1.
Fix: use the functional update form. It reads the latest state from
React's queue, no closure capture needed:
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
For non-counter state that the callback needs to *read* (not update),
useRef to mirror it:
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
return () => clearInterval(id);
}, []);
Reference: React docs — Synchronizing with Effects (stale closures).The fix
// components/Counter.tsx — fixed
import { useState, useEffect } from "react";
export function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}`setCount(c => c + 1)` reads the latest state from React's internal queue without capturing it in the closure. The empty dependency array is correct — the interval should not re-create on every render. For cases where the callback genuinely needs to *read* an arbitrary state value (not just update a counter), mirror the state into a ref and read `ref.current`.
Why human review missed it
Stale closures are React's most common concurrency bug. The dependency array discipline is non-obvious and the linter rules (`react-hooks/exhaustive-deps`) can flag it but often miss the case where the dep is intentionally omitted. Mesrai catches every `useEffect` with empty deps that uses state inside a setInterval, setTimeout, or event handler.
Related rules + further reading
Mesrai rule pack: language/react-stale-closure — flags useEffect empty-deps with state read inside async closure.
React docs: Synchronizing with Effects (stale closures section).
Top React bug by frequency in the projects we review.
Takeaway
Functional update form is the answer for counters. useRef for read-only access to other state. Mesrai catches every stale-closure shape on every React PR.