Topic 26.3
Hooks Traps: Stale Closures, Effect Loops and Dependencies
In one line
Most hooks bugs come from three mistakes: a function that keeps reading values from an old render (a stale closure), an effect whose dependencies change every render (an effect loop), and an effect that sets something up without cleaning it up; each has a reliable fix.
Think of it like this
A photo in your pocket. In the morning you take a photo of the classroom whiteboard. At lunch the teacher changes the board, but your photo still shows the morning version. If you keep reading the photo, you'll copy old homework. A stale closure is a function carrying an old photo of your state: it was made during one render and keeps seeing that render's values even after the screen has moved on.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Closure
- A function together with the variables it could see when it was created.
- Stale closure
- A function that keeps reading values from an old render because it was created then and is still being called.
- Updater function
- A function passed to a setter,
setCount((c) => c + 1), that receives the latest queued value. - Dependency array
- The list after an effect telling React which values it reads; the effect re-runs when one changes.
- Effect loop
- An effect that sets state, which re-renders, which makes the effect run again, without end.
- Referential equality
- Two objects are equal only if they are the very same object.
{}is never===another{}. - Cleanup
- The function an effect returns; React runs it before the effect runs again and on unmount.
- useEffectEvent
- A React 19.2 hook that makes a function for effects that always sees the latest props and state, without being a dependency.
Step by step
01Every render has its own values
Render 1 has count = 0 and handlers that see 0. Render 2 has count = 1 and new handlers that see 1. Nothing changes the 0 inside render 1's functions. If one of those functions is still running later, it still sees 0.
02Let the lint rule point at it
The react-hooks/exhaustive-deps rule reads your effect and lists what it uses. Here is its real output for the broken interval:
$ npx eslint src/StaleCounter.jsx
src/StaleCounter.jsx
8:6 warning React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)03Choose the fix by what the code needs
Next value from the previous one? Use an updater. Should the effect restart when a value changes? Add it to the deps. Need the latest value inside a long-lived callback without restarting? Use useEffectEvent (inside effects) or a ref. Started something? Return a cleanup.
04Objects as dependencies
const options = { query } makes a new object every render. As a dependency it is always "changed". The lint rule says so precisely:
$ npx eslint src/Results.jsx
src/Results.jsx
5:9 warning The 'options' object makes the dependencies of useEffect Hook (at line 8) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'options' in its own useMemo() Hook react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)05Cleanups stop leaks and stale listeners
An effect with [count] re-runs on every change. Without a cleanup, each run adds a listener and none are removed. With a cleanup, React removes the old listener before adding the new one, so exactly one is active and it sees the latest count.
Try it yourself
- 1
Fix the stale counter with dependencies instead
In Trap 1, keep
setCount(count + 1)but change the deps to[count]and movelet ticksoutside the component (so it survives restarts). Predict: does it reach 5? (Yes, but the interval is torn down and restarted after every tick. Correct, but wasteful: the updater is the better fix.) - 2
Remove the safety stop in your head first
In the loop demo, what would happen without
if (renders > 60) return;? Don't run it: reason it out. Then apply the fix from the next example to this one and check the render count drops to 2. - 3
Wrap the leak in StrictMode
Wrap the leaky demo's two components in
<StrictMode>(import it). Predict how manyleakylines print now. (Five: StrictMode's extra mount added one more listener that was never removed, while the clean component still prints one line.)
Code & diagrams
Both intervals tick five times. The stale one sets `0 + 1` five times; the fixed one adds one to the latest value each time.
Preview
Console
Nothing logged yet.Expected console output
Stale: 1
Fixed: 5After the `await`, `qty` is still the value from the click's render. The ref is one shared box, so it shows the newest value. Writing a ref during render is acceptable only for this "latest value" pattern; React 19.2's `useEffectEvent` is the cleaner tool inside effects.
Preview
Console
Nothing logged yet.Expected console output
snapshot qty: 1
latest qty via ref: 3Without the safety stop this would render forever. React counts nested updates from effects and, past 50, prints the warning below. The page still works, but the CPU never rests.
Preview
Console
Nothing logged yet.Expected console output
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
renders so far: 61Two renders: the first with an empty list, the second after the effect sets the results. The effect runs again only when `query` really changes.
Preview
Console
Nothing logged yet.Expected console output
renders so far: 2After three clicks the leaky component has four listeners, each a stale closure over a different count. The clean one has exactly one, with the latest value.
Preview
Console
Nothing logged yet.Expected console output
leaky sees count 0
leaky sees count 1
leaky sees count 2
leaky sees count 3
clean sees count 3The interval starts once. The first tick adds 1; after the step changes to 10 the next three ticks add 10 each: 1 + 30 = 31. `useEffectEvent` functions may only be called from inside effects, never during render or passed to other components.
Preview
Console
Nothing logged yet.Expected console output
interval started
Count: 31Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the dependency array entirely
In the fixed loop example, delete [query] so the effect has no dependency array.
Break #2
Call setState unconditionally during render
In any component, write setCount(count + 1); directly in the function body (not in a handler or effect).
Myth vs fact
Myth
Stale closures are a React bug.
Fact
They are JavaScript closures doing exactly what they should. React makes each render a snapshot on purpose so handlers see consistent values.
Myth
Adding every value to the deps array always fixes it.
Fact
It makes the effect correct, but objects and functions created in render will then re-run it every time. Fix the dependency itself: use primitives, move it inside, or memoise.
Myth
Disabling the exhaustive-deps lint rule is fine if you know what you're doing.
Fact
It is the most common source of stale-closure bugs. If you need a non-reactive read, use useEffectEvent or a ref, and keep the rule on.
Myth
An empty dependency array means "run once".
Fact
It means "this effect reads no reactive values". In development StrictMode runs it twice to check the cleanup, and if it does read props or state, those values are frozen at the first render.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The stale-closure problem is the price of render snapshots, which are what make concurrent rendering safe: a render can be paused and resumed without its values shifting underneath it (Topic 26.1).
- ▸
useEffectEvent(React 19.2) replaces the old "latest ref" pattern for effects: the function always sees current props and state and isn't reactive. Rules: call it only from effects (or other effect events), don't pass it to children, and don't list it in deps. - ▸
The React Compiler (Topic 16.5) memoises objects and functions created in render, which removes many object-dependency loops automatically, but it doesn't change closure semantics: a stale interval is still stale.
- ▸
When an object dependency comes from props (the parent creates it), the fix belongs to the parent (memoise it there) or the child can depend on its primitive fields:
[options.query, options.limit].
Remember this
- 1
A closure is a function plus the variables it could see when it was created (see
/javascript, closures). Every render of a component is a fresh function call with its owncount, its own props and its own handlers (Topic 4.4: state as a snapshot). A handler or effect created in render 1 closes over render 1'scount. That is correct most of the time. It becomes a bug when the function lives longer than its render: an interval started once, asetTimeout, anawaitin the middle of a handler, a subscription callback. - 2
Trap 1: the interval that stops at 1.
useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, [])runs once, so the interval callback forever seescount = 0and keeps setting 1. Fixes, best first: an updatersetCount((c) => c + 1)(no need to readcountat all); list the value in the deps so the effect re-syncs (restarts the interval each time); or read the latest value through a ref oruseEffectEvent(Topic 7.9, React 19.2) when the callback must read something without restarting the effect. Thereact-hooks/exhaustive-depslint rule catches the original mistake and even suggests the updater. - 3
Trap 2: the async handler that reads old state. In
async function checkout() { await save(); console.log(qty) },qtyis the value from the render where the click happened, even if the user changed it during theawait. Often that is what you want (send what the user saw when they clicked). When you really need the newest value, keep it in a ref updated each render, read it from the server's response, or pass it as an argument. Don't "fix" it by adding asetTimeout. - 4
Trap 3: the effect loop. An effect that sets state re-renders the component; if a dependency changes on every render, the effect runs again, sets state again, and so on. The usual culprit is an object, array or function created during render used as a dependency:
{ query }is a new object each render, and React compares dependencies withObject.is(Topic 7.2), so it always looks changed. React's development build detects this and printsMaximum update depth exceeded. Fixes: depend on primitive values (queryinstead ofoptions), create the object inside the effect, move constants outside the component, or memoise withuseMemo/useCallback(Topic 16.4). Also ask whether the effect is needed at all: derived data should be computed during render (Topic 7.6). - 5
Trap 4: the missing cleanup. Every effect that subscribes, listens, starts a timer or opens a connection must return a cleanup that undoes it (Topic 7.3). Without it, each re-run adds another listener, and the old ones still run with their old closed-over values: a leak and a stale closure at once. StrictMode's mount → unmount → mount in development (Topic 7.4) exposes this immediately by doubling the effect.
- 6
How to debug any of these. Ask three questions: which render created this function? Which values does it read? Does it live longer than that render? Then pick the tool: an updater for "next from previous", correct dependencies for "re-sync when this changes", a ref or
useEffectEventfor "read the latest without re-syncing", and a cleanup for "undo what I started". Turn oneslint-plugin-react-hooks: itsexhaustive-depsrule flags missing dependencies and objects that change every render, and version 7 also ships the React Compiler-powered rules (Topic 16.5).
Explain it without notes
What is a stale closure in React? Give an example and three ways to fix it.
Why does an object in the dependency array cause an effect to run every render?
What does a missing effect cleanup cause, and how does StrictMode help you find it?
When would you use useEffectEvent instead of adding a value to the dependency array?
Practice
Fix this so the countdown reaches 0: useEffect(() => { const id = setInterval(() => setLeft(left - 1), 1000); return () => clearInterval(id); }, []);
This effect loops. Fix it without disabling the lint rule: const filters = { tag }; useEffect(() => { setRows(applyFilters(all, filters)); }, [filters]);
Add the missing cleanup: useEffect(() => { document.addEventListener("keydown", onKey); }, [onKey]);
Trade-offs
- ↔
Updaters and correct deps keep code reactive and simple; refs and
useEffectEventare escape hatches that make it easy to hide real dependencies, so use them only for genuinely non-reactive reads. - ↔
Memoising dependencies fixes loops but adds code; often the better fix is removing the effect and computing during render.
Done when you can
I can explain and demo a stale closure in an interval and in an async handler.
I can pick between an updater, deps, a ref and
useEffectEvent.I can spot and fix an effect loop caused by an object dependency.
I write a cleanup for every effect that starts something.
I can read the exhaustive-deps lint messages and act on them.