React countdown stuck at 1: stale closure in a setInterval inside useEffect
Our session timeout banner counted from 0 to 1 and then froze. The interval callback captured the first render's state, so it kept computing 0 + 1 forever. Functional state updates, and a ref-based useInterval hook for callbacks that read props, fixed it for good.
We shipped a small "you have been idle" banner in our admin app: after 25 minutes of inactivity it shows a counter of seconds since the warning appeared, and logs the user out at 300. QA reported that the counter showed 1 and never moved. Users were never logged out by the banner either; the server-side session expiry eventually did it, with a much less friendly error page.
There was no error in the console. The component rendered twice (0, then 1) and then stayed silent, even though a console.log inside the interval callback printed every second.
tick, seconds = 0
tick, seconds = 0
tick, seconds = 0
tick, seconds = 0
Reproducing it
The banner reduced to this component:
function IdleBanner({ onTimeout }) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log("tick, seconds =", seconds);
setSeconds(seconds + 1);
if (seconds + 1 >= 300) onTimeout();
}, 1000);
return () => clearInterval(id);
}, []); // run once on mount
return <div role="alert">Idle for {seconds}s</div>;
}
Mount it, wait three seconds, and the screen shows Idle for 1s while the log keeps printing seconds = 0.
What we ruled out
- The interval being cleared. The log line kept printing once per second, so the timer was alive.
- StrictMode double mounting. In development React 18 StrictMode runs the effect, cleanup, then the effect again. That creates and clears one extra interval, but the bug reproduced identically in a production build without StrictMode.
- Batching swallowing updates. React 18 batches updates from timers, but batching merges updates within one tick. It does not drop updates spread across seconds.
Root cause
The effect ran once, on the first render, and the arrow function passed to setInterval closed over that render's seconds, which was 0. Every tick called setSeconds(0 + 1). The first call changed state from 0 to 1 and caused a re-render; every following call set state to 1 again, which React bails out of because the value is unchanged. The onTimeout check also read the stale 0, so it could never reach 300.
What is happening under the hood
Each render of a function component is a separate function call with its own local variables. seconds is not a live binding to state; it is a constant captured for that render. Event handlers and effect callbacks created during that render see that render's values forever.
useEffect with an empty dependency array keeps only the first render's callback. The interval scheduled inside it therefore runs code that belongs to render one, no matter how many renders follow. This is ordinary JavaScript closure behaviour, not a React bug; hooks just make it easy to keep an old closure alive for a long time.
The react-hooks/exhaustive-deps lint rule exists to flag exactly this. On our code it reported:
React Hook useEffect has missing dependencies: 'onTimeout' and 'seconds'.
Either include them or remove the dependency array.
The rule was configured as warn and the CI lint step only failed on errors, so the warning sat unread.
Environment
| Component | Version / value |
|---|---|
| React / react-dom | 18.3.1 |
| Build | Vite 5.4, production and dev builds both affected |
| Lint | eslint-plugin-react-hooks 4.6.2, exhaustive-deps set to warn |
| Browsers | Chrome 131, Firefox 132, Safari 18.1 |
| Node (tests) | 20.18 LTS, Vitest 2.1 |
Minimal reproduction
import { useEffect, useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
useEffect(() => {
const id = setInterval(() => setN(n + 1), 1000);
return () => clearInterval(id);
}, []);
return <p>{n}</p>; // shows 0, then 1, then stays at 1
}
Who is affected
Any component that starts a long-lived callback (interval, subscription, WebSocket handler, addEventListener) inside an effect and reads state or props in that callback without listing them as dependencies. All React versions with hooks behave this way.
The fix
There are two separate stale reads in the banner, and they need different fixes.
Updating state from previous state. Use the functional form of the setter. React passes the latest state to the updater, so the closure never needs to read seconds:
useEffect(() => {
const id = setInterval(() => {
- setSeconds(seconds + 1);
+ setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(id);
}, []);
That alone fixes the counter.
Reacting to the new value. The logout check should not live inside the timer callback at all. It is a reaction to state, so it belongs in its own effect that depends on that state:
useEffect(() => {
if (seconds >= 300) onTimeout();
}, [seconds, onTimeout]);
Since onTimeout comes from the parent, we wrapped it in useCallback there so the effect does not re-run on every parent render.
A reusable hook for callbacks that must read fresh props. Elsewhere we had a poller whose callback needed the current filter props each tick. Re-creating the interval on every prop change would reset its timing, so we keep the interval stable and store the latest callback in a ref:
import { useEffect, useRef } from "react";
export function useInterval(callback, delayMs) {
const saved = useRef(callback);
// always point at the latest callback, after each commit
useEffect(() => {
saved.current = callback;
}, [callback]);
useEffect(() => {
if (delayMs === null) return; // pass null to pause
const id = setInterval(() => saved.current(), delayMs);
return () => clearInterval(id);
}, [delayMs]);
}
The final banner:
function IdleBanner({ onTimeout, limit = 300 }) {
const [seconds, setSeconds] = useState(0);
useInterval(() => setSeconds((s) => s + 1), seconds < limit ? 1000 : null);
useEffect(() => {
if (seconds >= limit) onTimeout();
}, [seconds, limit, onTimeout]);
return <div role="alert">Idle for {seconds}s</div>;
}
The interval also pauses itself at the limit by passing null, so it does not keep firing after logout starts.
Alternatives and trade-offs
- Adding
secondsto the dependency array. Correct, but the effect then clears and recreates the interval every second. For a one-second counter that is harmless; for an interval of 30 seconds that changes state more often than it fires, it would never fire. useReducerwith atickaction. Same benefit as the functional updater, and nicer when a tick updates several fields.- Storing the start time instead of counting. For timers that must be accurate, store
startedAtand renderMath.floor((Date.now() - startedAt) / 1000). Background tabs throttle intervals to once per second or less often, so a counter that increments drifts; a timestamp does not. We adopted this for the logout limit itself.
How to test the fix
Vitest fake timers make the interval deterministic:
// IdleBanner.test.jsx
import { render, screen, act } from "@testing-library/react";
import { vi, test, expect } from "vitest";
import { IdleBanner } from "./IdleBanner";
test("counts every second and calls onTimeout at the limit", () => {
vi.useFakeTimers();
const onTimeout = vi.fn();
render(<IdleBanner onTimeout={onTimeout} limit={3} />);
act(() => vi.advanceTimersByTime(2000));
expect(screen.getByRole("alert").textContent).toBe("Idle for 2s");
act(() => vi.advanceTimersByTime(1000));
expect(onTimeout).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(5000));
expect(screen.getByRole("alert").textContent).toBe("Idle for 3s");
vi.useRealTimers();
});
npx vitest run src/IdleBanner.test.jsx
Against the original component, the first assertion fails with expected 'Idle for 1s' to be 'Idle for 2s'.
Confirming in production
We tracked an idle_logout event emitted by the banner. In the week after release it fired for about 4 percent of admin sessions, where before it had never fired once. Server-side "session expired" error pages from the admin app dropped by roughly the same amount.
Regression guard
react-hooks/exhaustive-depsis nowerror, and CI fails on lint errors. We fixed the 23 existing warnings it surfaced, three of which were other stale closures.useIntervallives in our shared hooks package with the test above; rawsetIntervalinside components is flagged in review.
Security implications
The bug had a real security edge: an idle-logout feature that silently never fires leaves admin sessions open on unattended machines for longer than policy allows. The client-side timer is a convenience, not a control, so we also confirmed the server-side idle timeout matches the documented 30 minutes. The fix itself introduces no new attack surface.
Prevention checklist
- Use functional state updates whenever new state depends on old state inside timers or subscriptions.
- Treat
exhaustive-depswarnings as errors. - Move "react to a value" logic into an effect that depends on that value instead of reading it inside a timer.
- Keep a tested
useIntervalhook with a ref for the latest callback. - Base time-sensitive UI on timestamps, not increment counters.
References
- React docs, useEffect: https://react.dev/reference/react/useEffect
- React docs, useState (updating state based on the previous state): https://react.dev/reference/react/useState
- React docs, Separating events from effects: https://react.dev/learn/separating-events-from-effects
- eslint-plugin-react-hooks: https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
- Vitest, fake timers: https://vitest.dev/guide/mocking.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/react-useeffect-stale-closure-setinterval" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
Redux-Saga takeLatest silently dropped saves: cancelled tasks on concurrent edits
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.