Frontend / React / 18_stale_closures_and_hook_rules.md

Stale Closures and the Rules of Hooks

Updated 5 interview angles 7 min read source
On this page13
  1. What’s a closure, briefly?
  2. How does this become “stale” in React?
  3. Show me the four common fixes.
  4. Stale closures outside useEffect?
  5. What are the Rules of Hooks?
  6. Why do those rules exist?
  7. What does ESLint enforce?
  8. How does use() (React 19) interact with the rules?
  9. Custom hooks — same rules?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Stale Closures and the Rules of Hooks

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.

What’s a closure, briefly?

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.

js
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.

How does this become “stale” in React?

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.

tsx
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.

Show me the four common fixes.

1. Functional setState — never reads from the closure:

tsx
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):

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

Refs and Effect Events

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

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

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

4. Effect Events (useEffectEvent, stable since React 19.2) — the “I want the latest value without firing the effect” escape hatch:

tsx
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.

Stale closures outside useEffect?

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.

What are the Rules of Hooks?

  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*).
tsx
// Wrong
function Component({ flag }) {
  if (flag) {
    // conditional → breaks the rule
    const [x, setX] = useState(0);
  }
}

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

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

Why do those rules exist?

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.

ts
// 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.

What does ESLint enforce?

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.

json
{
  "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.

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

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

tsx
function Maybe({ flag }: { flag: boolean }) {
  if (flag) {
    // ok — `use` is allowed to be conditional
    const value = use(promise);
    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.

Custom hooks — same rules?

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.

tsx
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("");
  // legal — top-level call to custom hook
  const debouncedQuery = useDebounced(query, 300);
}

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

Gotchas

  • 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 closures — console.log the value the effect sees vs the current state; if they diverge, you have a stale closure.
  • useState lazy initializer — useState(() => expensiveInit()) ensures expensiveInit runs only on first render. Forgetting the function wrap re-runs it every render.

Further reading

Interview angle 5

  • “What is a stale closure?” - a function held across renders that captured the values in scope when it was created. The interval you set up on mount still sees the count from render zero, because that is the count its closure captured.
    • Follow-up: “How do you fix one?” - four options, in rough order of preference: functional setState so you never read the old value, put the value in the deps so the closure is recreated, hold a ref when you need the latest without re-subscribing, or an effect event for the “read latest, do not re-fire” case.
  • “Why can’t hooks be called conditionally?” - React identifies state by call order, not by name. A hook skipped on one render shifts every subsequent slot, so your useState reads another hook’s value. The linter catches this and disabling it is how you get corrupted state.
  • “Is there an exception?” - use(). It is deliberately safe to call conditionally, because it is designed for reading a promise or context in a branch. It is the first hook that breaks the rule, and it breaks it by design rather than by accident.
  • “What do you do about an exhaustive-deps warning?” - treat it as the canary for a stale closure and restructure the effect. Silencing it removes the warning and keeps the bug, and the comment explaining why the dep is genuinely unnecessary is almost never written.
  • “Why does the use* naming convention matter?” - it is what tells the linter that a function contains hooks, so the ordering rules are enforced inside it too. A custom hook named without the prefix is unchecked.