Fixing Stale Closures in React useCallback and useMemo Hooks
You added useCallback to memoize a function and useMemo to cache a derived value. Everything looks correct. Then a user clicks a button and nothing updates β or worse, it updates with data from two renders ago. That's a stale closure, and it's one of the most disorienting bugs React can throw at you.
Stale closures don't crash your app. They just make it quietly wrong, which is what makes them hard to track down.
- Understand what a stale closure actually is and why React hooks create them
- Identify stale closures in
useCallbackanduseMemousing a repeatable process - Fix stale closures with correct dependency arrays and the
useRefescape hatch - Apply the
useReducerpattern when closures become impossible to manage - Use ESLint's exhaustive-deps rule to catch problems before they reach production
What a Closure Actually Is
A closure is a function that captures variables from its surrounding scope at the time the function was created. In plain JavaScript, this is a feature: inner functions can read and write outer variables without any ceremony.
In React, every render creates a new scope with fresh copies of state and props. A function defined during render closes over the values in that specific render's scope. If you memoize that function with useCallback and React reuses it across renders, the function still holds the values from when it was originally created β not the current ones.
function Counter() {
const [count, setCount] = React.useState(0);
// This callback is only created once (empty dep array).
// 'count' inside it will always be 0.
const logCount = React.useCallback(() => {
console.log('Count is:', count);
}, []); // <-- stale closure
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={logCount}>Log count</button>
</div>
);
}Click "Increment" five times, then click "Log count". The console prints Count is: 0 every time. The callback was created once, it captured count = 0, and it never let go.
Why the Dependency Array Exists
The dependency array is React's mechanism for telling the memoization hooks when to throw away the cached version and create a new one. When you list a value in the array, React compares it to the previous render's value using Object.is. If something changed, the hook runs again and the new function or value closes over fresh data.
The contract is simple: every reactive value that the callback reads must appear in the dependency array. A reactive value is anything that can change between renders β state, props, context values, or variables derived from them.
// Correct: count is listed as a dependency
const logCount = React.useCallback(() => {
console.log('Count is:', count);
}, [count]);
Now logCount is recreated whenever count changes, so it always reads the current value. The trade-off is that child components receiving logCount as a prop will also re-render when count changes β but that's usually acceptable and correct behavior.
The Most Common Patterns That Go Wrong
Missing state in a callback
This is the classic case shown above. A callback reads state but the dependency array is empty or incomplete. The fix is straightforward: add the missing value to the array.
Object and array dependencies
Objects and arrays fail the Object.is comparison on every render because they're created fresh each time. If you put an object in the dependency array, the hook re-runs on every render β which defeats memoization entirely.
// This re-runs on every render because 'options' is a new object each time
const result = React.useMemo(() => {
return compute(options);
}, [options]); // 'options' is defined as {} in render scopeThe fix is to either memoize the object itself with useMemo, or pull out the primitive values you actually need and list those as dependencies.
const result = React.useMemo(() => {
return compute({ threshold, limit });
}, [threshold, limit]); // primitive values, stable comparisonFunctions as dependencies
Passing a callback as a dependency to another useCallback or useMemo is a common source of cascading re-creation. If the parent function isn't itself memoized, it becomes a new reference every render, causing every downstream hook to re-run.
// fetchData is recreated every render β everything depending on it re-runs too
function Parent() {
const fetchData = () => fetch('/api/data'); // not memoized
const process = React.useCallback(async () => {
const data = await fetchData();
return transform(data);
}, [fetchData]); // always stale or always re-created
}Wrap fetchData in useCallback with its own dependencies, or move it outside the component if it doesn't depend on any reactive values.
The useRef Escape Hatch
Sometimes you genuinely want a stable function reference that always has access to the latest values β without adding those values to the dependency array. A common example is an event handler you're passing to a third-party library that caches the callback reference internally.
The pattern is to store the latest version of your callback in a ref, and expose a stable wrapper that reads from the ref on each call.
function useStableCallback(fn) {
const fnRef = React.useRef(fn);
// Keep the ref current on every render
React.useLayoutEffect(() => {
fnRef.current = fn;
});
// Return a stable function that delegates to the ref
return React.useCallback((...args) => {
return fnRef.current(...args);
}, []); // empty array is safe here because we never read fn directly
}
Use useLayoutEffect rather than useEffect here so the ref is updated synchronously before the browser paints β this prevents a tiny window where the ref could be stale during the same render cycle.
This pattern is useful, but reach for it deliberately. If you use it everywhere, you lose the referential stability guarantees that make memoization worthwhile in the first place.
Using useReducer to Escape Closure Complexity
When a callback reads multiple pieces of state and updating one triggers reading another, the dependency array grows fast and the logic becomes hard to follow. useReducer offers a cleaner exit.
Dispatch functions from useReducer have a stable reference β they never change. This means a useCallback that only dispatches actions has an empty (or near-empty) dependency array with no staleness risk.
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = React.useReducer(reducer, initialState);
// dispatch is stable β no stale closure possible
const increment = React.useCallback(() => {
dispatch({ type: 'increment' });
}, []); // empty array is genuinely correct here
return (
<div>
<p>Count: {state.count}</p>
<button onClick={increment}>Increment</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</div>
);
}The reducer itself has full access to the current state at execution time. You never close over state values in the callback, so staleness isn't possible.
Functional State Updates as a Simpler Fix
For the common case of updating state based on its current value, you don't need a reducer at all. React's state setter accepts a function that receives the current state as its argument.
// Instead of closing over 'count':
const increment = React.useCallback(() => {
setCount(count + 1); // stale if count is not in deps
}, [count]);
// Use the functional form:
const increment = React.useCallback(() => {
setCount(c => c + 1); // always correct, no dependency needed
}, []);This pattern works any time the new state is a pure function of the old state. It's simpler than a reducer and requires no architectural change.
Catching Stale Closures with ESLint
Manual dependency array audits don't scale. The eslint-plugin-react-hooks package ships a rule called exhaustive-deps that statically analyzes your code and flags missing or unnecessary dependencies.
npm install --save-dev eslint-plugin-react-hooksAdd it to your ESLint config:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}Set it to "error" rather than "warn" in a serious project β stale closures are correctness bugs, not style issues. The rule won't catch every possible pattern, but it eliminates the most common mistakes before they reach code review.
Common Gotchas to Watch For
Commenting out deps to silence the lint rule. This is the most dangerous anti-pattern in React hooks. If the lint rule says to add something, either add it or understand exactly why it's safe to omit and use a useRef-based approach intentionally.
Assuming useMemo is always pure memoization. React may discard cached values in some future scenarios (the documentation mentions this for concurrent features). Don't use useMemo to prevent side effects β only to skip expensive calculations.
Overusing useCallback. A new function reference on every render is only a problem if that function is passed as a prop to a memoized child, used in another hook's dependency array, or registered as an external event listener. Adding useCallback to every function in a component adds overhead without benefit.
Forgetting that context values can cause stale closures too. If your callback reads a value from useContext, that value is reactive. Omitting it from the dependency array is the same bug as omitting state.
Next Steps
Stale closures in useCallback and useMemo are a matter of understanding the relationship between React's render model and JavaScript's closure semantics. Once that clicks, the dependency array stops feeling like a chore and starts feeling like documentation.
- Enable
eslint-plugin-react-hookswithexhaustive-depsset to"error"in your project today - Audit any existing
useCallbackwith an empty dependency array and verify each one is intentionally stable or uses theuseRefpattern - Refactor complex multi-state callbacks to use
useReducerand dispatch instead of closing over state directly - Replace any
setCount(count + 1)-style updates with functional formsetCount(c => c + 1)wherever the new value depends only on the old one - Read through the React documentation on "Removing Effect Dependencies" β the same mental model applies to all hooks, not just effects
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!