Skip to story

Javascript

Closures and Stale State in Handlers

10 min read · May 31, 2026 ★ Flagship

Reading level

The counter that always says 1

You build a click counter. You click the button 5 times quickly. The count says 1. You add a console.log — it prints the right number inside the handler. But the state update only shows the last value you started with. What's happening?

You've hit a stale closure. Your event handler captured an old version of count when it was created — and every click is updating from that same old starting point instead of the latest value.

Stale closures appear in React hooks, setInterval callbacks, event listeners added once but reading dynamic state, and async functions that run after state has already changed. The root is always the same: the function captured a variable at definition time, not at call time.

The closure is working correctly — it's a design decision that closures capture bindings at creation time, not lazily. The bug is in assuming that a closure over a let binding in a React component's render scope stays fresh across renders. It doesn't. Each render creates a new scope; old closures hold the old scope's values.

What a closure actually captures

A closure is a function that remembers the variables from where it was created. Think of it as a function carrying a backpack of variables.

function makeGreeter(name) {
  return function greet() {
    console.log('Hello, ' + name); // captured 'name'
  };
}
const greetAlice = makeGreeter('Alice');
greetAlice(); // "Hello, Alice" — always, even if nothing called 'Alice' exists anymore

The backpack contains a snapshot of name at the moment makeGreeter ran. That's fine for a greeter. The problem comes when the backpack should have today's value, but it still holds yesterday's.

In React, each render is a function call. useState returns the current value for this render. An event handler created in this render closes over this render's values. If that handler is called in a future render (e.g., from a setTimeout, setInterval, or an unremoved event listener), it reads the past render's values.

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ captures count from first render — always 0 + 1 = 1
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps — only runs once, captures count = 0 forever
}

The eslint-plugin-react-hooks exhaustive-deps rule exists specifically to surface this pattern. When you suppress it with // eslint-disable, you are choosing to accept a stale closure — sometimes intentionally (stable refs), sometimes by mistake.

The two canonical solutions operate at different layers: functional updates break the closure dependency entirely; refs provide a mutable container that closures can always read fresh.

Three stale closure traps

Trap 1: setInterval with empty deps

// ❌ count is always 0 inside the interval
useEffect(() => {
  setInterval(() => setCount(count + 1), 1000);
}, []); // captured count = 0, never updates

Trap 2: async handler reading stale state

// ❌ Clicks during the fetch read the count from when the handler was created
async function handleClick() {
  await fetch('/api/save');
  console.log(count); // stale — not the count at save time
}

Trap 3: addEventListener without cleanup

// ❌ Each render adds a new listener; old ones hold old closures
useEffect(() => {
  window.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') submit(formData); // stale formData
  });
}); // no cleanup, no deps

Each trap shares the same root: a function created in one render scope is called in a context where state has moved forward. The function's backpack still holds the old state.

The eslint exhaustive-deps warning on trap 1 is: React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. Adding count to deps "fixes" the lint warning but creates a new interval on every count change — a different bug.

Trap 3 is common in legacy code migrated from class components, where this.state was always read fresh. Function components don't have this — each render's function scope is its own frame on the call stack, not a shared mutable object.

Three clean fixes

Fix 1: functional state update (breaks the closure dependency)

// ✅ The updater function receives the latest state as an argument
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // prev is always current — no closure on count
  }, 1000);
  return () => clearInterval(id);
}, []); // safe with empty deps now

Fix 2: useRef for latest value access

// ✅ Ref is mutable — closures always see the latest .current
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });

async function handleClick() {
  await fetch('/api/save');
  console.log(countRef.current); // fresh — always the latest count
}

Fix 3: proper cleanup with correct deps

// ✅ Add dep + cleanup — handler recreated when formData changes
useEffect(() => {
  const handler = (e) => {
    if (e.key === 'Enter') submit(formData); // fresh formData
  };
  window.addEventListener('keydown', handler);
  return () => window.removeEventListener('keydown', handler);
}, [formData]); // re-run when formData changes

Rule of thumb for choosing:

  • If you only need to update state based on previous state → functional update
  • If you need to read state inside an async or long-lived callback → ref
  • If the dependency is an input or callback prop → exhaustive deps + cleanup

A fourth pattern — useEffectEvent (React 19 / experimental) — creates an event handler that always reads the latest state without being a dependency. It formalises the "ref to latest callback" pattern into a first-class primitive. Watch for it landing in stable React.

Pattern at a glance

Annotated example: setInterval in useEffect — stale closure vs correct deps

❌ STALE CLOSURE

useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // always 0
  }, 1000);
  return () => clearInterval(id);
}, []); // empty deps — count frozen at 0

Empty dep array: count is always 0 in the closure

✅ FUNCTIONAL UPDATE

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => {
      console.log(prev); // always latest
      return prev + 1;
    });
  }, 1000);
  return () => clearInterval(id);
}, []); // safe — no dep on count

Functional update: closure always captures current value via prev

Try it: stale vs fresh counter

Click "Broken" and rapidly click the +1 button many times — you'll see the stale closure effect. Switch to "Fixed" and the same fast clicking works correctly.

The broken version uses setCount(count + 1); the fixed version uses setCount(prev => prev + 1). That one word — "prev" — breaks the closure dependency.

Under the hood: React batches state updates in event handlers. The broken version reads the same count value for every batched call. The functional updater queues correctly regardless of batching.

⚡ Interactive demo

React's exhaustive-deps rule is your best friend

Install eslint-plugin-react-hooks (it ships with Create React App and Vite React templates). It warns you about missing dependencies. Don't suppress the warnings — they're almost always pointing at a real bug.

The exhaustive-deps rule tells you when your closure captures a value it didn't declare as a dependency. Three valid responses:

  1. Add the dep (re-run effect when it changes)
  2. Use a functional updater (remove the dep need entirely)
  3. Use a ref (mutable escape hatch for stable callbacks)

Suppressing with // eslint-disable-next-line is almost always wrong.

useEffectEvent (RFC #220, available behind experimental_useEffectEvent in React 18.3) resolves the long-standing tension between exhaustive deps and stable callback identity. It wraps a function so it always reads fresh state while remaining stable as a reference:

// React 19 (experimental_useEffectEvent in 18.3)
import { experimental_useEffectEvent as useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme); // reads latest theme, stable identity
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', onConnected); // safe — onConnected is stable
    return () => connection.disconnect();
  }, [roomId]); // theme NOT in deps, but always reads latest
}

References

Remember

Key takeaways

  • A closure captures the value of a variable when the function is created — not when it's called.
    Each React render creates a new scope; closures over state from one render don't auto-update when state changes in a later render.
    The stale closure pattern is specifically: function created in render N holds render N's state, called in render N+M — always reading N.
  • Use setCount(prev => prev + 1) instead of setCount(count + 1) when updating state in async or interval callbacks.
    Functional state updates break the closure dependency — the updater receives the latest state as its argument regardless of when it runs.
    useRef is the stable mutable container for closures that need to read (not update) latest state — it survives re-renders without triggering them.
  • The exhaustive-deps ESLint rule is almost always right — don't suppress it without understanding why.
    Three valid responses to a missing-deps warning: add the dep, use functional update, or use a ref. Suppressing is rarely the right answer.
    useEffectEvent (React 19) formalises the "ref to latest callback" pattern — watch for it stabilising as the canonical solution to this class of bug.

Enjoyed this case?

Case 2 of 3 in Javascript · 14 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.