Stale closures, dependencies and effect events
Why an interval or listener created in an effect keeps seeing old state, and three correct fixes: functional updates, refs for the latest value, and useEffectEvent. Plus why you should never silence the dependency lint rule.
Comes up in: React, Coding
Learn#
Pooja’s timer shows 1 and stops counting, although the interval keeps firing. She used setCount(count + 1) inside an effect with an empty dependency array.
A stale closure is a function that remembers values from an old render (JS.02.03). Each render has its own count. An interval created on the first render only ever sees that render’s count:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); // a DOM for Node; in a browser you already have one
const { createElement: h, useEffect, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
function Stale() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 100); // count is always 0 here
return () => clearInterval(id);
}, []); // the lint rule would warn: count is missing
return h("p", null, `stale: ${count}`);
}
function Fixed() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 100); // reads the latest value
return () => clearInterval(id);
}, []);
return h("p", null, `fixed: ${count}`);
}
const box = document.createElement("div");
const root = createRoot(box);
root.render(h("div", null, h(Stale), h(Fixed)));
await new Promise((r) => setTimeout(r, 450));
console.log([...box.querySelectorAll("p")].map((p) => p.textContent).join(" | "));
root.unmount();Output (verified)
stale: 1 | fixed: 4
The functional update setCount(c => c + 1) does not need count at all, so nothing can go stale.
State
- render 1: count = 0
- interval callback sees count = 0
Page
empty
Render 1: count is 0. The effect (deps []) creates an interval whose callback closes over this render's count, 0.
Read the steps as text
- Render 1: count is 0. The effect (deps []) creates an interval whose callback closes over this render's count, 0.
- Tick: setCount(count + 1) sets 0 + 1 = 1. Render 2 shows 1, but the effect does not re-run, so the old callback stays.
- Every later tick sets 0 + 1 = 1 again. The counter is stuck: a stale closure.
- setCount(c => c + 1) asks React for the current value each time, so the same old callback works.
Predict#
Predict first
You add count to the dependency array of the interval effect. Does the counter work?
Listing count makes the effect re-run after every change, so each tick tears down and creates a new interval. It works, but timing can drift. The functional update avoids the dependency entirely, which is why it is the best fix here.
Interview angle#
Say it like this — about 30 seconds
“A stale closure happens when a long-lived callback, such as an interval or event listener created in an effect, keeps values from the render that created it. The fix depends on the case. If I only need the previous state, I use a functional update. If the value must stay a dependency, I list it and let the effect re-sync. If some logic should read the latest props without restarting the effect, I use useEffectEvent, or a ref updated each render. I never silence the exhaustive-deps rule.”
Follow-up What is a stale closure in React? Show an example and fix it.
Follow-up What is useEffectEvent for?
Follow-up How does a ref fix stale values?
Practice#
Guided
Build the core idea of useEffectEvent: a stable function that always calls the latest version.
Done when
callalways runs the most recent function given toupdate.- A callback captured once (like an interval) still sees the newest values.
- Arguments and return values pass through.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
What is a stale closure?
A callback that keeps values from the render that created it.
Best fix for setCount(count + 1) in an interval?
The functional update setCount(c => c + 1).
What does useEffectEvent give you?
A function that reads the latest values but is not an effect dependency.
Should you disable the exhaustive-deps lint rule?
No. It points at stale-closure bugs.
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
Every function created during a render closes over that render’s props and state. Event handlers are re-created each render, so they are fresh; callbacks created once in an effect are not.
Check yourself: Why does the interval callback see count 0 forever?
It was created in render 1 and the effect never re-ran, so it closes over render 1's count.
How it works underneathSenior rounds
const latest = useRef(value); useEffect(() => { latest.current = value; }); then read latest.current in the long-lived callback (RCT.03.03).Check yourself: Where should you write ref.current = value?
In an effect (or event handler), not during render.
Edge cases and trapsWhere people slip
useEffectEvent must only be called from inside effects, never during render or passed to children Source: React: useEffectEvent.Check yourself: Can you pass an effect event to a child component?
No. Call effect events only from inside effects.
In productionStaff-level answers
react-hooks/exhaustive-deps on. If it asks for a dependency you do not want, change the code: move logic into an effect event, use an updater, or move the object creation inside the effect Source: React: Removing effect dependencies.Check yourself: What does the exhaustive-deps rule protect you from?
Effects and memos that use values they do not list, which go stale.
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-11-01. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- What is a stale closure in React? Show an example and fix it.
- What is
useEffectEventfor?