5 min read
0%

Stale Closures in React

Back to Blog
Stale Closures in React

Stale Closures in React

A stale closure is a function that captured a value from a previous render and still references that old value. It’s one of the most common sources of subtle bugs in React hooks.

The Basic Problem

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // count is always 0 — captured on mount
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps: effect runs once, closes over count=0

  return <div>{count}</div>;
}

The count never gets past 1. The interval callback captured count = 0 on mount and never gets a fresh copy.

Fix 1: Functional Update

Use the updater function form to avoid reading stale state:

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // receives current value at dispatch time
  }, 1000);
  return () => clearInterval(id);
}, []);

The updater c => c + 1 receives the current state at the moment it’s dispatched, not at capture time. No stale closure.

Fix 2: Correct Dependencies

Add the captured value to the dependency array:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // effect re-runs when count changes

This creates a new interval on every count change — correct, but potentially not the intended behavior.

Fix 3: useRef for Mutable Values

For callbacks that need the latest value without re-running the effect:

function useLatest(value) {
  const ref = useRef(value);
  useEffect(() => { ref.current = value; });
  return ref;
}

function Counter() {
  const [count, setCount] = useState(0);
  const latestCount = useLatest(count);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(latestCount.current + 1); // always current
    }, 1000);
    return () => clearInterval(id);
  }, []);
}

Stale Closures in Event Handlers

function App() {
  const [message, setMessage] = useState('hello');

  const handleClick = useCallback(() => {
    console.log(message); // stale if message is missing from deps
  }, []); // forgot to add message
}

Adding message to useCallback deps fixes it but regenerates the callback on every change — trade-off between freshness and stability.

useReducer Avoids the Problem

useReducer dispatch is always stable and the reducer always receives current state:

const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'INCREMENT' }); // no stale closure — no state read
  }, 1000);
  return () => clearInterval(id);
}, []);

Canvas is not supported in your browser