Back to the Library

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.”

In This Post

What useCallback Is Actually DoingThe Frozen Snapshot ProblemThe Fix: List the DependencyWhen You Can Remove the Dependency EntirelyThe Version That Hides Until the Second ClickWhen You Need Stable Reference and Fresh ValuesThe Same Bug Inside Custom HooksRecognizing This Pattern EverywherePractice These Patterns

Practice This Pattern

Blue Belt

Log Button Always Records a Blank Entry

A message logger where every entry is blank - useCallback closes over the initial empty string and never sees what the user typed.

+25 KI
Enter the Dojo
Blue Belt

Tab Switch Shows the Wrong Content

A tabbed panel where every click shows Topic 1 - the handler closes over the initial activeId and looks up the wrong topic on every selection.

+25 KI
Enter the Dojo
Black Belt

Search Hook Ignores What You Type

A custom search hook that only filters on mount - the useEffect inside has an empty dep array, permanently ignoring every subsequent query change.

+50 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

You type a message. Click the button. An empty entry appears in the log.

Or you click a tab. The display shows the previous tab. Or the first tab. Or whatever was showing when the page loaded.

Your useCallback has a stale closure. Here is what that means and how to fix it.

What useCallback Is Actually Doing

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.

The Frozen Snapshot Problem

const [message, setMessage] = useState("");
 
const handleLog = useCallback(() => {
  setLog((prev) => [...prev, message]); // message frozen at ""
}, []);

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.

The Fix: List the Dependency

Add the variable to the dependency array. React will recreate the function whenever that value changes, giving the new version a fresh closure.

// broken - message permanently ""
const handleLog = useCallback(() => {
  setLog((prev) => [...prev, message]);
}, []);
 
// fixed - handleLog recreated when message changes
const handleLog = useCallback(() => {
  setLog((prev) => [...prev, message]);
}, [message]);

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.

When You Can Remove the Dependency Entirely

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 update
const handleIncrement = useCallback(() => {
  setCount(count + 1);
}, [count]);
 
// reads nothing - stable reference, no dep needed
const 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.

The Version That Hides Until the Second Click

Sometimes the stale value is not the direct input. It hides inside a lookup:

const handleSelect = useCallback((id) => {
  setActiveId(id);
  const topic = TOPICS.find((t) => t.id === activeId); // reads stale activeId
  setDisplay(topic.content);
}, []);

id is correct - it is the tab the user clicked. But activeId is the frozen snapshot from mount, permanently 1. So TOPICS.find always returns Topic 1.

The fix: use the argument that was passed, not the closed-over state.

const handleSelect = useCallback((id) => {
  setActiveId(id);
  const topic = TOPICS.find((t) => t.id === id); // reads from the argument
  setDisplay(topic.content);
}, []);

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.

When You Need Stable Reference and Fresh Values

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 nothing
 
const 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.

The Same Bug Inside Custom Hooks

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.

Recognizing This Pattern Everywhere

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.

Practice These Patterns

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.