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
useRefthat 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:
- Only call hooks at the top level — not inside loops, conditions, or nested functions.
- 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
useEffectis forbidden (if (x) useEffect(...)); butuseEffect(() => { if (x) doThing(); }, [x])is fine — the conditional is inside the effect. - Render-time logging surfaces stale closures —
console.logthe value the effect sees vs the current state; if they diverge, you have a stale closure. useStatelazy initializer —useState(() => expensiveInit())ensuresexpensiveInitruns 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-depswarns 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
useEffectdeep dive (where stale closures bite most): use_effect_deep.md- Effect events (the modern stale-closure escape): same file
- React 19
use(): react_19.md - React Profiler (debug stale renders): ../15_performance/09_react_profiling.md
Further reading
- React docs — Rules of Hooks: https://react.dev/reference/rules/rules-of-hooks
- React docs — Removing Effect Dependencies: https://react.dev/learn/removing-effect-dependencies
- React docs — Separating Events from Effects: https://react.dev/learn/separating-events-from-effects
- Dan Abramov — “A Complete Guide to useEffect”: https://overreacted.io/a-complete-guide-to-useeffect/