Web Development Bug Report

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.

John Nguyen John Nguyen @devbugs · November 24, 2024 · 8 min read · 40 Views
Banner: a React timer stuck at 1 because an interval callback reads stale state

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 seconds to 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.
  • useReducer with a tick action. 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 startedAt and render Math.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-deps is now error, and CI fails on lint errors. We fixed the 23 existing warnings it surfaced, three of which were other stale closures.
  • useInterval lives in our shared hooks package with the test above; raw setInterval inside 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-deps warnings 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 useInterval hook with a ref for the latest callback.
  • Base time-sensitive UI on timestamps, not increment counters.

References

Add a label

#react #useeffect #javascript #hooks #closures
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.