Command Palette

Search for a command to run...

Hectal
PHASE 26Advanced ~33 min· topic 3 of 5

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.

Every render has its own valuesdiagram
Rendering diagram…

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:

Terminalwhole filebash
$ 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.

Choose the fix by what the code needsdiagram
Rendering diagram…

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:

Terminalwhole filebash
$ 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.

Cleanups stop leaks and stale listenersdiagram
Rendering diagram…

Try it yourself

  1. 1

    Fix the stale counter with dependencies instead

    In Trap 1, keep setCount(count + 1) but change the deps to [count] and move let ticks outside 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. 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. 3

    Wrap the leak in StrictMode

    Wrap the leaky demo's two components in <StrictMode> (import it). Predict how many leaky lines 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

Trap 1: the interval that sticks at 1, and the fix New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Stale: 1
Fixed: 5
Trap 2: an async handler sees the snapshot New tab

After 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

snapshot qty: 1
latest qty via ref: 3
Trap 3: an object dependency makes an effect loop New tab

Without 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

Press Run to see the page.

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: 61
Trap 3 fixed: depend on the primitive New tab

Two 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

renders so far: 2
Trap 4: a missing cleanup piles up listeners New tab

After 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

Press Run to see the page.

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 3
useEffectEvent: read the latest value without restarting React 19.2+ New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

interval started
Count: 31

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

terminal
$ Console
── what you'll see ──
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.

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

terminal
$ Console
── what you'll see ──
Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

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. 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 own count, 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's count. That is correct most of the time. It becomes a bug when the function lives longer than its render: an interval started once, a setTimeout, an await in the middle of a handler, a subscription callback.

  2. 2

    Trap 1: the interval that stops at 1. useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, []) runs once, so the interval callback forever sees count = 0 and keeps setting 1. Fixes, best first: an updater setCount((c) => c + 1) (no need to read count at 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 or useEffectEvent (Topic 7.9, React 19.2) when the callback must read something without restarting the effect. The react-hooks/exhaustive-deps lint rule catches the original mistake and even suggests the updater.

  3. 3

    Trap 2: the async handler that reads old state. In async function checkout() { await save(); console.log(qty) }, qty is the value from the render where the click happened, even if the user changed it during the await. 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 a setTimeout.

  4. 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 with Object.is (Topic 7.2), so it always looks changed. React's development build detects this and prints Maximum update depth exceeded. Fixes: depend on primitive values (query instead of options), create the object inside the effect, move constants outside the component, or memoise with useMemo/useCallback (Topic 16.4). Also ask whether the effect is needed at all: derived data should be computed during render (Topic 7.6).

  5. 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. 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 useEffectEvent for "read the latest without re-syncing", and a cleanup for "undo what I started". Turn on eslint-plugin-react-hooks: its exhaustive-deps rule 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

01

What is a stale closure in React? Give an example and three ways to fix it.

02

Why does an object in the dependency array cause an effect to run every render?

03

What does a missing effect cleanup cause, and how does StrictMode help you find it?

04

When would you use useEffectEvent instead of adding a value to the dependency array?

Practice

01

Fix this so the countdown reaches 0: useEffect(() => { const id = setInterval(() => setLeft(left - 1), 1000); return () => clearInterval(id); }, []);

02

This effect loops. Fix it without disabling the lint rule: const filters = { tag }; useEffect(() => { setRows(applyFilters(all, filters)); }, [filters]);

03

Add the missing cleanup: useEffect(() => { document.addEventListener("keydown", onKey); }, [onKey]);

Trade-offs

  • ↔

    Updaters and correct deps keep code reactive and simple; refs and useEffectEvent are 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.