Tech0 views

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:

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:

tsx
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:

But the actual situation is:

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:

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:

tsx
useEffect(() => {
  const id = setInterval(() => {
    console.log('count:', count);
  }, 2000);

  return () => clearInterval(id);
}, [count]);

Now the behavior changes:

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:

tsx
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

tsx
useEffect(() => {
  const id = setInterval(() => {
    fetchData(query);
  }, 5000);

  return () => clearInterval(id);
}, [query]);

Advantages:

Disadvantages:

Solution B: Use a ref to hold the latest value

tsx
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:

When to use A, when to use B?

2. Stale parameters in async requests

Let's look at another example closer to real business:

tsx
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:

tsx
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:

tsx
const handleAdd = useCallback(() => {
  setCount(count + 1);
}, []);

It looks like you're "optimizing" the function reference, but in reality the logic is broken:

This is also why many articles emphasize:

useCallback is 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

tsx
const handleAdd = useCallback(() => {
  setCount(count + 1);
}, [count]);

Fix B: If calculating new state based on old state, use functional update

tsx
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:

tsx
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:

tsx
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:

So don't treat it as a talisman.

4. What exactly does useCallback do?

Let's start with the most accurate statement:

useCallback is 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

tsx
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:

Use case 2: To avoid unnecessary re-runs when used as a dependency of another Hook

For example:

tsx
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:

tsx
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

tsx
const handleClick = () => {
  setOpen(true);
};

Such a function doesn't need useCallback at all.

Because:

2. After wrapping, the dependency list is long and still changes every time

tsx
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:

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:

tsx
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:

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:

tsx
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:

A straightforward but frequently rebuilding fix:

tsx
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:

tsx
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:

These two goals are different, and the code should reflect that.

8. 6 conclusions to take away from this article

  1. Stale value issues in React are almost always closure issues, not mysterious React failures.

  2. The purpose of the dependency array is to tell React when to discard the old closure and create a new one.

  3. Whenever the next state depends on the previous state, prefer functional updates.

  4. For scenarios like external subscriptions and timers where you "don't want frequent rebuilding", use refs to store the latest values.

  5. useCallback only caches function references; it doesn't automatically give you the latest value, nor does it fix incorrect logic.

  6. Only use useCallback when the stable function reference truly brings value; otherwise it just adds noise to the code.

SHARE

Share

Share this article.