Stale closures in useCallback — setCount(count+1) vs setCount(prev => prev+1)
A useCallback with `[]` captures state from the first render. setCount(count + 1) keeps using 0; setCount(prev => prev + 1) always reads the latest. Prefer functional updates whenever the new state depends on the previous.
This is the most common React hooks bug. The pattern looks innocent and the bug is silent — clicks stop working past the first tick.
The buggy version.
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, []); // empty deps — function created onceClick once → count goes 0 → 1. Click again → still 1. Click again → still 1.
Why. useCallback(fn, []) returns the same function across re-renders. That function closes over the count variable from the render that created it — which was the first render, where count === 0. Every click runs setCount(0 + 1). The setState fires; React re-renders; the new count is 1; but the closure still has the original 0 baked in.
Fix 1: functional update.
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);setCount accepts a function whose argument is the latest state at the time the update is applied. The closure no longer needs to read count — it doesn't reference any state value. Counts increment normally.
Fix 2: declare the dependency.
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);Now React recreates the callback whenever count changes, capturing the fresh value. Works, but defeats the point of useCallback — the function identity changes every render, so memoized children re-render anyway. Use this only if you also need the value of count inside the callback for something setCount can't do.
Functional updates are the right default. Whenever your new state is a function of the previous state — increment, append, toggle, decrement — prefer the functional form. It's:
- Stable: the callback identity stays the same.
- Correct under batching and concurrent rendering: React may run pending updaters in sequence, each receiving the previous result.
- Free from stale-closure bugs.
setCount(prev => prev + 1); // increment
setItems(prev => [...prev, newItem]); // append
setOpen(prev => !prev); // toggle
setMap(prev => new Map(prev).set(k, v)); // map insertWhen NOT to use functional updates. When the new state isn't a function of the old:
setUser(newUserFromAPI); // no prev needed
setQuery(""); // reset to known valueThe same bug bites useEffect.
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // stale: always sees the initial count
}, 1000);
return () => clearInterval(id);
}, []); // empty depsSame fix: setCount(c => c + 1) lets you keep the empty deps array without going stale.
Detection. ESLint's react-hooks/exhaustive-deps flags missing dependencies in useCallback/useEffect/useMemo. It's the single most valuable React lint rule — if it screams, listen.
Mental model. A hook's deps array is "what values from render scope this code reads." If you read a value, it must be in deps. If you don't want to depend on a value, use a ref or a functional updater.
Edge: derived computations. If the callback needs the latest value of count and also calls something that isn't a setState, store count in a ref:
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
const handleClick = useCallback(() => {
console.log("current:", countRef.current);
setCount(c => c + 1);
}, []);Refs don't trigger renders and they're always current. Use sparingly — they leak imperative thinking back into React.
Code
Follow-up questions
- •Why does the empty-deps callback still see the initial count?
- •When would you use a ref over a functional update?
- •What does the react-hooks/exhaustive-deps lint rule do?
- •How does this interact with concurrent rendering and batching?
Common mistakes
- •Disabling the exhaustive-deps lint rule to silence warnings — silent stale-closure bugs.
- •Adding deps until the lint stops complaining, even when functional update would be cleaner.
- •Reaching for refs immediately instead of functional updates.
- •Using functional updater inside a render-time computation — meant only for setState.
Performance considerations
- •Functional updater keeps callback identity stable → memoized children skip re-renders.
- •Adding state to deps invalidates the callback every change — defeats useCallback purpose.
Edge cases
- •Strict Mode dev runs setState twice — make sure your updater is pure.
- •Multiple setCount(prev => …) in one tick compose: each receives the previous result.
- •If the updater throws, React aborts the batch.
Real-world examples
- •Every counter / toggle / list-append in React. The bug appears in code reviews constantly.