frontend / react / stale_closures_and_hook_rules.md

Stale Closures and the Rules of Hooks

7 min read source

Stale Closures and the Rules of Hooks

TL;DR

A stale closure is a function that captured a value from an earlier render and is still being called with that captured value. It’s the most common bug in long-lived effects, event handlers, and any callback held across renders. The fix is one of: (a) functional setState, (b) include the value in deps and recreate the callback, (c) a ref holding the latest value, (d) effect events (React 19+). Separately, the Rules of Hooks — “call hooks at the top level, in the same order, every render” — exist so React can match each hook call to its slot in the component’s state.

Interview Q&A

Q: What’s a closure, briefly?

A: A function plus the variables from its surrounding scope at the time of creation. JS captures by reference for variables in the enclosing scope; the function sees their value at the moment it’s called, but the variable bindings are from when the function was defined.

function makeCounter() {
  let count = 0;
  return () => ++count;
}
const inc = makeCounter();
inc(); // 1
inc(); // 2

inc closes over count from makeCounter’s scope. The function lives on; count lives with it.

Q: How does this become “stale” in React?

A: Every render in React creates new function objects (for callbacks, effect bodies, etc.). Each captures the values from that render. If a callback is stored across renders (in an effect, a ref, a third-party listener), it captures values from when it was set up — not the current ones.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);          // captures count from THIS render
      setCount(count + 1);          // same — uses captured count
    }, 1000);
    return () => clearInterval(id);
  }, []);                           // empty deps — effect runs once → closure captures count=0 forever
}

The interval fires every second, but the callback was created during the first render with count = 0. So it logs 0, 0, 0, ... and sets count to 0 + 1 = 1 over and over.

Q: Show me the four common fixes.

A:

1. Functional setState — never reads from the closure:

useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000);    // reads from prev state, not closure
  return () => clearInterval(id);
}, []);

2. Include the value in deps + recreate (the “exhaustive deps” answer):

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, [count]);                       // recreates interval every count change — fine for slow updates, wasteful for fast

3. Ref holding the latest value — read from a mutable container:

const latestCount = useRef(count);
useEffect(() => { latestCount.current = count; });   // update each render

useEffect(() => {
  const id = setInterval(() => setCount(latestCount.current + 1), 1000);
  return () => clearInterval(id);
}, []);

4. Effect Events (React 19+ experimental) — the “I want the latest value without firing the effect” escape hatch:

const onTick = useEffectEvent(() => setCount(count + 1));   // reads latest count
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

The right fix depends on the situation. Functional setState is the cleanest when applicable; effect events handle the more general case where you need fresh values without re-firing.

Q: Stale closures outside useEffect?

A: Anywhere a function is held across renders:

  • Event listeners attached to DOM via useRef that aren’t updated.
  • Subscription callbacks (WebSocket onmessage, RxJS observers, etc.).
  • Throttle/debounce-wrapped functions that keep a reference to the wrapped function.

The fix is the same: don’t capture stale; use functional setState, refs, or effect events.

Q: What are the Rules of Hooks?

A:

  1. Only call hooks at the top level — not inside loops, conditions, or nested functions.
  2. Only call hooks from React functions — components or custom hooks (functions named use*).
// Wrong
function Component({ flag }) {
  if (flag) {
    const [x, setX] = useState(0);   // conditional → breaks the rule
  }
}

// Wrong
function helper() {
  const [x, setX] = useState(0);     // called outside React function
}

// Right — conditional logic inside the hook
function Component({ flag }) {
  const [x, setX] = useState(0);
  if (!flag) return null;
  return <div>{x}</div>;
}

Q: Why do those rules exist?

A: React tracks state by call order, not by name. Each useState call corresponds to a slot in the component’s “hook list.” On the first render, hooks are called in order — React records what each was. On subsequent renders, React expects the same hooks in the same order so it can match each call to the same slot.

// Internal model — pseudocode
const hooks: Hook[] = [];
let cursor = 0;

function useState(init) {
  if (hooks[cursor] === undefined) hooks[cursor] = { state: init };
  const hook = hooks[cursor];
  cursor++;
  return [hook.state, (v) => { hook.state = v; rerender(); }];
}

Conditionally calling a hook shifts the cursor mid-render → slot N becomes slot N-1 → state belonging to one hook gets returned by another → catastrophic bugs.

Q: What does ESLint enforce?

A: react-hooks/rules-of-hooks enforces the call-order rule. react-hooks/exhaustive-deps enforces the dep-array completeness. Both are part of eslint-plugin-react-hooks and should be in every React project’s lint config.

{
  "extends": ["plugin:react-hooks/recommended"]
}

The “lie to the linter” temptation (// eslint-disable-next-line react-hooks/exhaustive-deps) is the source of half of all stale-closure bugs. Resist; refactor instead.

Q: How does use() (React 19) interact with the rules?

A: use() is the first hook that can be called conditionally or in a loop:

function Maybe({ flag }: { flag: boolean }) {
  if (flag) {
    const value = use(promise);    // ok — `use` is allowed to be conditional
    return <div>{value}</div>;
  }
  return null;
}

It’s a deliberate exception — use() is designed to read promises and contexts in any code path. Other hooks still follow the rules.

Q: Custom hooks — same rules?

A: Yes. A custom hook is a function starting with use* that calls other hooks. It must follow the rules in its own body, and consumers must follow them when calling the custom hook.

function useDebounced<T>(value: T, ms: number) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(id);
  }, [value, ms]);
  return debounced;
}

// Consumer
function Component() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounced(query, 300);   // legal — top-level call to custom hook
}

The naming convention use* is what ESLint uses to recognize a custom hook and enforce the rules inside.

Gotchas / edge cases

  • Stale closures in subscriptions (useEffect(() => sub.on("msg", handler), [])) — handler captures stale state. Same fixes.
  • useCallback’s callback can also be stale if deps are wrong — it stores the function across renders.
  • useRef’s value is mutable across renders but writing to it doesn’t trigger re-render. Useful as the “latest value” holder.
  • Early returns mid-component are fine as long as no hooks come after them on a code path that returns early.
  • Conditional useEffect is forbidden (if (x) useEffect(...)); but useEffect(() => { if (x) doThing(); }, [x]) is fine — the conditional is inside the effect.
  • Render-time logging surfaces stale closuresconsole.log the value the effect sees vs the current state; if they diverge, you have a stale closure.
  • useState lazy initializeruseState(() => expensiveInit()) ensures expensiveInit runs only on first render. Forgetting the function wrap re-runs it every render.

What a senior is expected to say

  • “Stale closures: a function held across renders captures values from when it was defined. The fix is functional setState, including the value in deps, a ref for the latest value, or effect events.”
  • “The Rules of Hooks exist because React tracks state by call order. Conditional calls misalign the slots and corrupt state. ESLint catches it; don’t disable.”
  • use() is the first hook to break the rule — it’s intentionally conditional-safe, designed for reading promises and contexts in any branch.”
  • react-hooks/exhaustive-deps warns are the canary for stale closures. Don’t disable; restructure.”
  • “Custom hooks follow the same rules — that’s why the use* naming convention exists.”

Cross-references

Further reading