- Published on
Closure Issues with Timers in useEffect
- Authors

- Name
- 乘方
About timers
- Once a timer is cleared, pending callback tasks will not run.
- A timer is destroyed automatically after its callback completes.
- Memory leak risk appears when component unmounts while pending timer callbacks still exist; closures keep referenced external variables alive.
Problem 1
Expected behavior: print click count 2 seconds after clicking.
// Problematic code
export default function Test() {
const [n, setN] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(n);
}, 2000);
});
return (
<div>
<h1>{n}</h1>
<button
onClick={() => {
setN((prevN) => prevN + 1);
}}
>
Click
</button>
</div>
);
}

Result: after opening page and clicking twice quickly, console prints 0 1 2 around two seconds later.
Why this happens:
- Initial render runs
useEffectand starts timer1. - First click triggers
setN, rerender happens,useEffectruns again, timer2 starts. - Second click triggers another rerender, timer3 starts.
useEffectruns after paint; each render forms its own closure, timer callback capturesnfrom that closure.- Three timers fire in sequence and print each captured value.
Fix: clear previous timer within 2 seconds (debounce-like effect).
useEffect(() => {
const timer = setTimeout(() => {
console.log(n);
}, 2000);
return () => clearTimeout(timer);
});

Problem 2
Expected behavior: coupon countdown. After 5 seconds show "Event ended".
// Problematic code
export default function Test() {
const [n, setN] = useState(5);
useEffect(() => {
const timer = setInterval(() => {
setN(n - 1);
console.log(n);
if (n === 0) {
clearInterval(timer);
}
}, 1000);
return () => {
clearInterval(timer);
};
});
return (
<div>
<h1>{n || "Event ended"}</h1>
</div>
);
}

Result: interval does not stop at the end, and logged values lag behind UI.
Why this happens:
- Timer never really stops: cleanup clears previous interval, but each
setNrerender creates a new interval again. - Logged value lags: callback reads stale
nfrom old closure.
Fix: make timer unique and read latest state through functional update (setN(prevN => prevN - 1)).
useEffect(() => {
const timer = setInterval(() => {
setN((prevN) => {
const next = prevN - 1;
console.log({ n1: n, n2: next });
!next && clearInterval(timer);
return next;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, []);

Because this unique interval is created once at mount, n1 still points to the first closure value (always 5).
Why can n2 always get the latest value?
setN(prevN => ...)is queued by React. During render, React processes update functions in order and passes the latest computed state to each function. In async scenarios like timers, closure-capturednmay be stale, but functional updates rely on React-managed current state, so they stay fresh.
Using useRef to bypass closure staleness
Principle
Local variables in function components (including state values in closure) are recreated each render.
A ref object persists across renders during the whole component lifecycle, and updating.currentdoes not trigger rerender.
So we can sync latest state intoref.currentevery render, then readref.currentin timer callbacks.
import { useState, useEffect, useRef } from "react";
export default function Test() {
const [n, setN] = useState(5);
const nRef = useRef(n);
useEffect(() => {
nRef.current = n;
});
useEffect(() => {
const timer = setInterval(() => {
const currentN = nRef.current;
console.log("Current n:", currentN);
if (currentN > 0) {
setN(currentN - 1);
} else {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return <h1>{n || "Event ended"}</h1>;
}
Note: this is a general pattern, useful not only for timers but also event listeners, requestAnimationFrame, and other async callbacks.