Skip to solution
hardFrontend

What causes stale closures in React hooks, and how do you fix them correctly?

1.2k views
01

Understand the problem

Question presented to candidate: "A setInterval inside a useEffect keeps logging the initial count even after the user has clicked several times. What is happening, and how do you fix it properly?"

What a strong answer should cover:

  • Every render creates new function objects that close over that render's props and state. A function stored somewhere long-lived keeps those values forever.
  • It is ordinary JavaScript closure behaviour, not a React bug — React just re-runs the function frequently, so it happens constantly.
  • The classic triggers: an empty dependency array around a timer or subscription, a callback stored in a ref or passed to a non-React API, and an omitted dependency.
  • The fixes, ranked: the functional updater (setCount(c => c + 1)) reads the latest state from React rather than the closure; correct dependencies so the closure is recreated; useEffectEvent for logic that must read the latest value without being reactive; a ref as the last resort.
  • The wrong fix: silencing the exhaustive-deps lint rule, which converts a visible bug into a silent one.
  • The related trap: a cleanup function should see its own render's values — that is correct, not stale.

Clarifying questions expected:

  • "Is the stale value being read, or written? Reading needs a fresh closure; writing usually needs the functional updater."
  • "Does the effect genuinely need to re-run when that value changes, or just read the latest?"

Code / implementation expected: Yes — the broken interval and at least two correct fixes.

reacthooksclosures
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes hooks and closures. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Both the bug in section 3 and the fix in section 5 were produced by runni

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The stale interval, and three correct fixes side by side
Run Playground
import { useState, useEffect, useRef, useCallback } from "react";

// ❌ BROKEN: the callback is created once and closes over count = 0 forever.
function StaleCounter() {
  const [count, setCount] = useState(0);
  const [seen, setSeen] = useState("—");

  useEffect(() => {
    const id = setInterval(() => setSeen(String(count)), 1000);
    return () => clearInterval(id);
  }, []); // eslint would flag count here, and it is right

  return <Row label="❌ empty deps" count={count} seen={seen} onClick={() => setCount((c) => c + 1)} />;
}

// ✅ FIX 1: the functional updater. The closure never reads state at all —
// it asks React, which knows the current value. No dependency needed.
function UpdaterCounter() {
  const [count, setCount] = useState(0);
  const [seen, setSeen] = useState("—");

  useEffect(() => {
    const id = setInterval(() => {
      setCount((current) => { setSeen(String(current)); return current; });
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <Row label="✅ functional updater" count={count} seen={seen} onClick={() => setCount((c) => c + 1)} />;
}

// ✅ FIX 2: declare the dependency honestly. Correct, but note it tears down
// and recreates the interval on every change, which resets its timing.
function DepsCounter() {
  const [count, setCount] = useState(0);
  const [seen, setSeen] = useState("—");

  useEffect(() => {
    const id = setInterval(() => setSeen(String(count)), 1000);
    return () => clearInterval(id);
  }, [count]);

  return <Row label="✅ correct deps" count={count} seen={seen} onClick={() => setCount((c) => c + 1)} />;
}

// ✅ FIX 3: a ref holding the latest value. The manual version of
// useEffectEvent — useful for handing a stable callback to a non-React library.
function RefCounter() {
  const [count, setCount] = useState(0);
  const [seen, setSeen] = useState("—");
  const latest = useRef(count);
  latest.current = count;               // updated on every render

  useEffect(() => {
    const id = setInterval(() => setSeen(String(latest.current)), 1000);
    return () => clearInterval(id);
  }, []);

  return <Row label="✅ ref to latest" count={count} seen={seen} onClick={() => setCount((c) => c + 1)} />;
}

function Row({ label, count, seen, onClick }) {
  const wrong = seen !== "—" && seen !== String(count);
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "6px 0" }}>
      <code style={{ minWidth: 190 }}>{label}</code>
      <button onClick={onClick}>+1</button>
      <span>state: <strong>{count}</strong></span>
      <span style={{ color: wrong ? "crimson" : "#161" }}>
        interval sees: <strong>{seen}</strong>
      </span>
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <StaleCounter />
      <UpdaterCounter />
      <DepsCounter />
      <RefCounter />
      <p style={{ color: "#666", fontSize: 13 }}>
        Click +1 a few times on each row and wait a second. The first row's
        interval stays stuck on the value from its first render; the other three
        keep up, by three different mechanisms.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 72 of 119 decoded in the React.js track. One more won't hurt.

Back to track