useCallback and the Closure Trap: Why Your React Code Gets Stale Values

If the previous article addressed "which logic should not be placed in useEffect", then this article deals with why the remaining effects and callbacks still exhibit strange behavior.
You've probably seen these scenarios:
The state printed inside a timer is always stale.
The parameters obtained in the request callback are not the current input value.
Passed a function to a child component, but after adding useCallback for "optimization", the logic broke instead.
ESLint warns about missing dependencies; when you add them, things re-run excessively; when you remove them, weird bugs appear.
These issues appear to be React pitfalls, but in reality, they are the result of two things combined: JavaScript closures and React working with render snapshots.
1. First, understand the problem thoroughly: What is a "stale value"?
Let's look at the most classic example:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count);
}, 2000);
return () => clearInterval(id);
}, []);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}Many people, when writing this kind of code for the first time, intuitively think:
When count changes, the timer should also see the latest count.
But the actual situation is:
The effect only runs once after the initial render.
The callback inside
setIntervalcloses over the count from the first render.So no matter how many times you click the button, the console will still show 0.
This is the so-called stale closure problem.
To understand it, there is a very crucial mental model:
Each render of a React function component produces a new set of props, state, and functions; any effect, callback, or event handler you define in this render gets the snapshot of that particular render.
So it's not that React "didn't update", but rather the function you created was born inside the old render.
2. Why do dependency arrays relate to closures?
The essence of the dependency array is not an "optimization item", but to tell React:
Which reactive values this effect/memo/callback uses;
When these values change, you need to recreate this logic.
TkDodo explains this thoroughly: The dependency relationship in Hooks is essentially about managing when closures should update.
Still the same example, if you add count to the dependencies:
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count);
}, 2000);
return () => clearInterval(id);
}, [count]);Now the behavior changes:
Every time count changes, the effect will first clear the old timer, then create a new one.
The new timer closes over the new count.
So the conclusion is not "the dependency array decides when to execute", but more accurately: > The dependency array decides when React should discard the old closure and replace it with a new one.
This is also why missing dependencies cause bugs. It's not because React is punishing you, but because you used a value without telling React that this logic should be updated along with it.
3. The 4 most common scenarios of closure pitfalls
1. Timers / Polling
This is the most typical, as seen earlier. Root cause: Timer callbacks don't automatically "connect" to the latest state; they capture the closure at the time of definition.
Incorrect approach:
useEffect(() => {
const id = setInterval(() => {
fetchData(query);
}, 5000);
return () => clearInterval(id);
}, []);If query can change, this is almost certainly a bug.
There are two common solutions:
Solution A: Honest dependency array
useEffect(() => {
const id = setInterval(() => {
fetchData(query);
}, 5000);
return () => clearInterval(id);
}, [query]);Advantages:
Most intuitive.
No hidden state.
Disadvantages:
Every time query changes, the timer gets rebuilt.
If you don't want the external subscription to be frequently rebuilt, this approach is not suitable.
Solution B: Use a ref to hold the latest value
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
const id = setInterval(() => {
fetchData(queryRef.current);
}, 5000);
return () => clearInterval(id);
}, []);The essence of this pattern:
The timer is registered only once;
But it reads data from a ref that is always updated, not from an old closure.
When to use A, when to use B?
If rebuilding is acceptable, prefer the dependency array; it's simple and straightforward.
If you don't want to rebuild the external subscription, then consider using a ref to hold the latest value.
2. Stale parameters in async requests
Let's look at another example closer to real business:
function SearchBox() {
const [keyword, setKeyword] = useState('');
useEffect(() => {
async function search() {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
console.log(data);
}
search();
}, []);
}If the dependency array is empty here, this request will always use the initial keyword. This is not an "occasional bug", but a deterministic error.
The correct approach is usually:
useEffect(() => {
let ignore = false;
async function search() {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
if (!ignore) {
console.log(data);
}
}
search();
return () => {
ignore = true;
};
}, [keyword]);This also handles another issue: race conditions. The official React documentation explicitly reminds that async requests in effects must not only have correct dependencies but also prevent older requests from returning after newer ones, causing outdated results.
3. Using stale state in event callbacks
A very subtle but common pitfall:
const handleAdd = useCallback(() => {
setCount(count + 1);
}, []);It looks like you're "optimizing" the function reference, but in reality the logic is broken:
The dependency array is empty, so this callback is always the version from the first render.
The
countinside it is always the initial value.Each button click executes
setCount(0 + 1).
This is also why many articles emphasize:
useCallbackis not meant to "make logic correct"; it only makes function references more stable. If the logic itself has incorrect dependencies, it will only stabilize the error.
There are two correct fixes here.
Fix A: Write correct dependencies
const handleAdd = useCallback(() => {
setCount(count + 1);
}, [count]);Fix B: If calculating new state based on old state, use functional update
const handleAdd = useCallback(() => {
setCount(prev => prev + 1);
}, []);The second is usually better because it no longer directly depends on count, thus less prone to stale value issues.
This experience is particularly important:
Whenever your next state depends on the previous state, prefer functional updates over reading the current state directly in the closure.
4. Over-optimization when passing callbacks to child components
Many people, after learning React.memo and useCallback, tend to enter a phase:
They wrap every function with useCallback, as if not wrapping is unprofessional.
For example:
const handleSelect = useCallback((id: string) => {
onChange(id, filters);
}, []);The problem with this code is not useCallback itself, but that it closes over stale filters and onChange.
If these values change, this callback is wrong.
The correct approach is:
const handleSelect = useCallback((id: string) => {
onChange(id, filters);
}, [onChange, filters]);Then someone might say: "But it still changes every time, so what's the point of wrapping with useCallback?"
The answer is: If the dependencies are always changing, then useCallback may be meaningless in the first place.
This is the most easily misunderstood point about useCallback:
It is not a magic that "keeps functions unchanged".
It just "reuses the old function when dependencies haven't changed; gives you a new function when dependencies change".
So don't treat it as a talisman.
4. What exactly does useCallback do?
Let's start with the most accurate statement:
useCallbackis used to cache function references, not to fix closures, nor is it a default performance optimization button.
It has two typical use cases.
Use case 1: To prevent unnecessary re-renders when used with memoized child components
const Item = React.memo(function Item({
onSelect,
item,
}: {
onSelect: (id: string) => void;
item: Item;
}) {
return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});
function List({ items }: { items: Item[] }) {
const handleSelect = useCallback((id: string) => {
console.log(id);
}, []);
return items.map(item => (
<Item key={item.id} item={item} onSelect={handleSelect} />
));
}The significance of useCallback here:
If the parent creates a new function on every render, a
React.memochild component might still re-render because the props reference changed.By stabilizing the function reference, memo can work more effectively.
Use case 2: To avoid unnecessary re-runs when used as a dependency of another Hook
For example:
const fetchUser = useCallback(async () => {
const res = await api.getUser(userId);
setUser(res);
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]);Here, fetchUser is a dependency of the effect; useCallback ensures it only changes when userId changes.
But note a more recommended approach:
useEffect(() => {
async function fetchUser() {
const res = await api.getUser(userId);
setUser(res);
}
fetchUser();
}, [userId]);If the function is only used inside the effect, it's usually simpler to define it inside the effect, and it avoids dependency headaches.
5. When should you NOT use useCallback?
This is the most practical section.
The problem in many teams is not "not knowing how to use useCallback", but "using it too much".
The following scenarios are usually not worth wrapping:
1. The function is not passed to a memo child component, nor used as a dependency
const handleClick = () => {
setOpen(true);
};Such a function doesn't need useCallback at all.
Because:
It is not part of a performance bottleneck.
It is not used as a dependency.
Wrapping it only increases cognitive load.
2. After wrapping, the dependency list is long and still changes every time
const handleSave = useCallback(() => {
submit(form, user, settings, permissions, locale);
}, [form, user, settings, permissions, locale]);If the dependencies already change every time, this callback is essentially not cached. At this point, the real solution is often not to keep wrapping, but to restructure state, slim down dependencies, or refactor component boundaries.
3. Treating useCallback as the default way to "fix closures"
This is the biggest misconception.
useCallback does not automatically get the latest value;
it only caches a function, and that function still has closures.
If the dependencies are wrong, useCallback will only cache the error longer.
So a very important judgment is:
If the code "would break without useCallback", it usually doesn't mean you should add useCallback, but rather that your data flow has a fundamental problem.
6. When facing closure issues, debug in this order of priority
Here is a debugging order that is very suitable for code review and daily troubleshooting.
Step 1: First suspect if the dependency array is missing dependencies
Whenever a reactive value is used in effect, memo, or callback, first check if the dependencies are complete.
Don't first react with:
"Is this a React bug?"
"Is useCallback unstable?"
"Is there an async issue?"
Most of the time, once you fix the dependencies, the problem goes away.
Step 2: If you are simply updating state based on old state, switch to functional updates
For example:
setTodos(prev => [...prev, newTodo]);
setCount(prev => prev + 1);This approach can significantly reduce your dependence on the current state in the closure.
Step 3: If you don't want to rebuild external subscriptions, use a ref to read the latest value
This is a universal technique for handling closure issues in timers, listeners, and third-party subscriptions.
But note that ref is a tool, not an escape hatch. If it's just a normal effect that should update with dependencies, then still prefer writing dependencies.
Step 4: Only use useCallback when there is real benefit
The criteria can be simple:
Is it used with
React.memo?Is it used as a dependency of another Hook?
Have you actually measured a performance problem?
If none, then it's probably not needed.
7. A complete refactoring example: From "stale value hell" to maintainable code
First, let's look at the bad code:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query);
}, []);
useEffect(() => {
const id = setInterval(() => {
handleSearch();
}, 5000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}There are two stale closures here:
handleSearchalways captures the initial query.The timer effect always captures the initial handleSearch.
A straightforward but frequently rebuilding fix:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query);
}, [onSearch, query]);
useEffect(() => {
const id = setInterval(() => {
handleSearch();
}, 5000);
return () => clearInterval(id);
}, [handleSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}This is correct, but the interval gets rebuilt every time query changes.
If the business requirement is "register the timer only once, but use the latest query each time", then switch to the ref pattern:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
const onSearchRef = useRef(onSearch);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
onSearchRef.current = onSearch;
}, [onSearch]);
useEffect(() => {
const id = setInterval(() => {
onSearchRef.current(queryRef.current);
}, 5000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}The key to this kind of code is not "advanced", but that you need to first clarify the goal:
Do you want the side effect to re-run when dependencies change?
Or do you want the side effect to run only once, but read the latest values?
These two goals are different, and the code should reflect that.
8. 6 conclusions to take away from this article
Stale value issues in React are almost always closure issues, not mysterious React failures.
The purpose of the dependency array is to tell React when to discard the old closure and create a new one.
Whenever the next state depends on the previous state, prefer functional updates.
For scenarios like external subscriptions and timers where you "don't want frequent rebuilding", use refs to store the latest values.
useCallbackonly caches function references; it doesn't automatically give you the latest value, nor does it fix incorrect logic.Only use
useCallbackwhen the stable function reference truly brings value; otherwise it just adds noise to the code.
Follow on Google
Add HeyBinyang as a preferred source on Google
If you'd like to keep finding my updates through Google, you can mark this site as a preferred source and make it easier to spot in relevant reading flows.
SHARE
Share
Share this article.