Stale Closures in Hooks

Why your hooks sometimes 'forget' the latest state, and how to fix the most notorious bug in React.

React5 min readConcept 15 of 46

What it is

A closure is a JavaScript feature where an inner function has access to the variables of its outer function.

In React, every render creates a brand new set of variables. A 'stale closure' happens when an asynchronous callback (like a timer, an event listener, or a useEffect) gets trapped holding onto the variables from an *old* render instead of the current one.

Why it matters

Stale closures are the most confusing, frustrating, and common source of bugs for React developers.

You might look at your screen and see count = 5, but inside your setInterval or setTimeout, if you log count, it says 0! Your function is literally living in the past.

How it happens

Imagine you write: useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, []).

Because the dependency array is empty [], the effect only runs on the very first render, when count is 0.

The interval callback captures that 0. Every second, it runs setCount(0 + 1). The screen updates to 1. A second later, it runs setCount(0 + 1) again. It gets stuck at 1 forever.

The Stuck Interval

Start the intervals. Notice how the 'Stale' counter gets permanently stuck at 1, while the 'Fixed' counter successfully counts up.

const [stale, setStale] = useState(0);
const [fixed, setFixed] = useState(0);
 
useEffect(() => {
const id = setInterval(() => {
// ❌ STALE: captured 'stale' on render 1
// Always evaluates as setStale(0 + 1)
setStale(stale + 1);
 
// ✅ FIXED: functional updater gets
// the latest state directly from React
setFixed(prev => prev + 1);
}, 1000);
 
return () => clearInterval(id);
}, []); // Lying to React!
Stale0
Fixed0

Start the interval. The 'Stale' counter is permanently stuck at 1 because the interval function is trapped in the past. The 'Fixed' counter bypasses the closure entirely.

Check yourself

Pick an answer to lock it in, then read why. Getting one wrong is part of how it sticks.

  1. 1What exactly is a 'stale closure' in the context of React?
  2. 2You write `useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, [])`. `count` starts at 0. What happens?
  3. 3What is the cleanest way to fix a stale closure when trying to increment a counter inside an interval?

Remember this

  • A stale closure happens when a callback captures variables from an old render.
  • It almost always happens in useEffect, setInterval, or event listeners.
  • If you lie about your dependency array, you will get stale closures.
  • Functional state updates (setCount(c => c + 1)) are the best weapon against stale closures.

Done with this concept?

Mark it complete to track your progress. No login needed.