Skip to the solution
HardFrontend7 min read

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

1.2k views

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.

  • react
  • hooks
  • closures

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 10 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe stale interval, and three correct fixes side by side
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 72 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What are Actions in React 19, and how do they change form handling?

Continue