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 (1)
Nice Article Sir!