Mesrai
Back to blog
// essayTechnical Deep Dive

React useState Race Condition in Stale Closures

Real PR catch: setInterval in useEffect with empty deps capturing useState — counter stuck. Functional-update fix.

Mesrai TeamJuly 28, 20268 min read

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

tsxcomponents/Counter.tsx
// 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.

textexploit
[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

textreview-comment.txt
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

tsxcomponents/Counter.tsx (fixed)
// 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.

// try

See it on your next PR.

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