Here are the most common “stale closure” bugs in React and how to fix each, with tiny, copy-pasteable examples.
1) setInterval
using an old state value
Bug: interval callback captured the value of count
from the first render, so it never increments past 1
.
import React, { useEffect, useState } from 'react';
export default function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// ⚠️ stale closure: callback sees count=0 forever
const id = setInterval(() => {
setCount(count + 1); // always 1
}, 1000);
return () => clearInterval(id);
}, []); // empty deps -> callback never updated
return <p>{count}</p>;
}
Fix A (recommended): use functional state update (no deps needed).
import React, { useEffect, useState } from 'react';
export default function GoodCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ✅ always uses the latest value
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Fix B: include count
in deps and recreate interval when it changes (less efficient).
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);
2) Async function reads an old prop/state
Bug: searchTerm
changes, but the async call uses the old term captured by the earlier render.
import React, { useEffect, useState } from 'react';
export default function BadSearch({ searchTerm }) {
const [results, setResults] = useState([]);
useEffect(() => {
async function run() {
// ⚠️ if not in deps, this may use an old searchTerm
const res = await fetch(`/api?q=${encodeURIComponent(searchTerm)}`);
setResults(await res.json());
}
run();
}, []); // ❌ missing searchTerm
return <pre>{JSON.stringify(results, null, 2)}</pre>;
}
Fix: put the variable in the dependency array (and handle race conditions with an abort flag if needed).
useEffect(() => {
let cancelled = false;
(async () => {
const res = await fetch(`/api?q=${encodeURIComponent(searchTerm)}`);
const data = await res.json();
if (!cancelled) setResults(data);
})();
return () => { cancelled = true; };
}, [searchTerm]);
3) Event listeners holding stale state/props
Bug: A window listener added once reads old value
.
import React, { useEffect, useState } from 'react';
export default function BadListener() {
const [value, setValue] = useState(0);
useEffect(() => {
function onKey(e) {
// ⚠️ uses value from the first render only
if (e.key === 'ArrowUp') setValue(value + 1);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []); // ❌ missing value
}
Fix A: include value
in deps so the listener updates (re-attach on change).
useEffect(() => {
function onKey(e) {
if (e.key === 'ArrowUp') setValue(v => v + 1);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [/* none needed if using functional update */]);
(Here we used the functional updater, so we don’t need value
in deps; the handler always increments from the latest value.)
Fix B (ref pattern): keep latest value in a ref, use a stable handler.
import React, { useEffect, useRef, useState } from 'react';
export default function GoodListener() {
const [value, setValue] = useState(0);
const valueRef = useRef(value);
valueRef.current = value; // keep ref synced
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowUp') setValue(valueRef.current + 1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return <p>{value}</p>;
}
4) Throttled/Debounced handlers with stale closures
Bug: throttled/debounced function created once, but references an old state
inside.
import React, { useMemo, useState } from 'react';
function throttle(fn, wait) { /* ... as before ... */ }
export default function BadThrottle() {
const [y, setY] = useState(0);
const onScroll = useMemo(
() => throttle((e) => {
// ⚠️ if we used y directly here, it might be stale
setY(e.nativeEvent.contentOffset.y);
}, 300),
[]
);
// This one is okay because we set y from the event directly.
// But if you *read* y inside the throttled function, it would be stale.
return null;
}
Fix A: avoid reading state inside throttled/debounced callbacks; derive from event payloads when possible.
Fix B (ref pattern): if you must read state, mirror it into a ref and read from the ref inside the throttled handler.
import React, { useMemo, useRef, useState, useEffect } from 'react';
function throttle(fn, wait) { /* ... */ }
export default function GoodThrottle() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
const onEvent = useMemo(
() => throttle(() => {
// ✅ always latest via ref
console.log('latest count =', countRef.current);
}, 300),
[]
);
return <button onClick={() => setCount(c => c + 1)}>Inc</button>;
}
5) Quick checklist to avoid stale closures
□ Use functional state updates: setState(prev => compute(prev))
□ Put every outside variable you use in a hook into the dependency array
□ Or, store the “latest” value in a ref if you need a stable callback
□ Recreate timers/listeners when their deps change (and clean up!)
□ For throttled/debounced funcs, prefer deriving from event args, or read from a ref
Top comments (0)