useCallback Always Returns the Wrong Value - Fix Stale Closures
6 min read
“useCallback keeps your function alive across renders. The side effect: that function is frozen at the moment it was born. The user typed something new. Your handler does not know.”
useCallback keeps your function stable across renders. Same function reference,
every render. That stability is useful - it stops memoized children from
re-rendering when the parent does.
But there is a cost: when useCallback stabilizes a function, it also freezes the
values that function closed over at the time it was created.
The dependency array controls when React creates a fresh copy with updated values.
An empty array means never.
When this component first renders, message is "". useCallback creates
handleLog and captures that empty string in its closure.
The user types "hello." message state updates to "hello". But handleLog is
the same function from mount. It still has "" locked in its closure. Click Log.
Empty entry every time.
Symptom
The handler fires correctly. Something gets added or sent. But the value is
wrong - empty, or whatever it was the moment the page first loaded. Changing the
input makes no difference to what the handler reads.
Now every time message changes, handleLog gets a new version that closes over
the current value. The handler always reads what the user actually typed.
The Sensei's Hint
Every variable your callback reads that is not a state setter belongs in the
dependency array. ESLint's react-hooks/exhaustive-deps rule catches this
automatically - it is worth enabling.
If your callback only reads state to compute the next value, the functional updater
form removes the need to close over it at all:
// reads count - must list it as a dep, reference changes every updateconst handleIncrement = useCallback(() => { setCount(count + 1);}, [count]);// reads nothing - stable reference, no dep neededconst handleIncrement = useCallback(() => { setCount((prev) => prev + 1);}, []);
React passes the actual current state as the argument to your updater. Your
callback never touches the closed-over value. Use this pattern for increments,
toggles, appends - anything that just transforms the previous state.
id comes in fresh on every call. It does not need to be in the dep array. This
version of the bug is common in tab panels and dropdowns - it hides because the
first click works correctly. The second click does not.
Adding a dependency like message means the function reference changes every time
message changes. If you are passing this callback to a React.memo child, that
child will re-render whenever message changes - which may defeat the point.
When you genuinely need both a stable reference and always-fresh values, store the
current value in a ref:
const messageRef = useRef(message);messageRef.current = message; // updated on every render, costs nothingconst handleLog = useCallback(() => { setLog((prev) => [...prev, messageRef.current]);}, []); // stable - memoized children will not re-render
The ref is always current. The callback reads the latest value without needing to
be recreated. But reach for this only when you actually need both things - most
stale closure bugs are fixed by just adding the missing dependency.
Constraints
An empty dependency array does not mean "no dependencies." It means "depends on
nothing from the component scope." If your callback reads any state or prop
variable, and is not using a functional updater, the array is wrong.
A custom hook with a useEffect and empty deps captures its arguments at mount
and ignores every change after that:
function useFilter(query) { const [results, setResults] = useState(ALL); useEffect(() => { setResults(ALL.filter((item) => item.includes(query))); }, []); // query is read here but not listed return results;}
The parent passes a new query on every keystroke. The hook receives it. But the
effect ran once at mount and will not run again. The filter is locked to the empty
string forever.
Add query to the effect's dep array inside the hook. Custom hooks follow the
exact same dependency rules as component-level effects.
Stale closures are not just a useCallback problem. The same failure appears in:
useEffect with missing deps - the effect reads an old value permanently
useMemo with missing deps - the computed value never updates
setTimeout or setInterval callbacks - they capture state at registration time
addEventListener callbacks attached inside a useEffect with []
The diagnostic is always the same: the function runs, produces a result, and that
result corresponds to values from an earlier render. Find the missing dependency.
Add it. Or restructure to avoid reading the value directly.
Three katas isolate this at increasing depth. The log button kata is the clearest
entry point - empty deps, every entry is blank, cause and effect are direct. The
tab panel kata shows the version where the stale value hides inside a lookup. The
custom hook kata is the hardest: the closure is one level inside the hook, removed
from where the symptom appears.