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.
In depth
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.
function makeCounter() {
let count = 0;
return () => ++count;
}
const inc = makeCounter();
inc(); // 1
inc(); // 2inc 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.
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:
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 fast3. Ref holding the latest value — read from a mutable container:
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 (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.
Stale closures outside useEffect?
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.
What are the Rules of Hooks?
- 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) {
// 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.
// 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.
{
"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:
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.
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 / 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 5
- “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): useEffect Deep — Cleanup, StrictMode Double-Invoke, Dep-Array Bugs, Effect Events- Effect events (the modern stale-closure escape): same file
- React 19
use(): React 19 — Actions, useActionState, useOptimistic, use(), ref as Prop - React Profiler (debug stale renders): React Profiling and the Cost Model of Re-renders
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/