Fixing Stale Closures in React useCallback and useMemo Hooks

May 19, 2026 7 min read 58 views
Abstract illustration of interconnected loops and nodes symbolizing JavaScript closures and React hook dependency chains

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 useCallback and useMemo using a repeatable process
  • Fix stale closures with correct dependency arrays and the useRef escape hatch
  • Apply the useReducer pattern 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 scope

The 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 comparison

Functions 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-hooks

Add 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-hooks with exhaustive-deps set to "error" in your project today
  • Audit any existing useCallback with an empty dependency array and verify each one is intentionally stable or uses the useRef pattern
  • Refactor complex multi-state callbacks to use useReducer and dispatch instead of closing over state directly
  • Replace any setCount(count + 1)-style updates with functional form setCount(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 save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.