Home/Learn/React/Stale Closures and Update Timing

Stale Closures and Update Timing

Advanced
State & Effects

A callback captures the state from the render that created it. When that callback outlives the render — a timer, a listener, a subscription — it keeps reading values that are no longer current.

Overview

This is the bug that makes people say React is confusing, and it is entirely explained by closures from the JavaScript track. Each render creates new functions that close over that render's props and state. A function stored somewhere that outlives its render — inside setInterval, an event listener added once, a subscription callback — keeps that old snapshot forever. The symptom is a counter stuck at 1, or a WebSocket handler that always sees the initial filter. There are three standard fixes, and knowing which to apply is the skill.

The Bug

An interval set up once, closing over the first render's state.

The counter that stops at 1
function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)      // 'count' is 0 in this closure, forever
    }, 1000)
    return () => clearInterval(id)
  }, [])                       // set up once — captures the first render

  return <p>{count}</p>        // goes 0 -> 1, then stops
}

// Every tick computes 0 + 1. The interval was created during the
// first render and closed over that render's count.

// Fix 1 — the functional updater. React supplies the current value,
// so the closure does not need it:
setCount(c => c + 1)           // correct, and the deps stay empty

// Fix 2 — depend on it honestly, accepting the effect re-runs:
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000)
  return () => clearInterval(id)
}, [count])                    // tears down and recreates each second

When the Value Is Not State

The functional updater only helps for state you are setting. For anything else, a ref holds the latest value.

The latest-ref pattern
// A socket handler that must always see the CURRENT filter,
// without tearing the socket down whenever the filter changes
function useLiveResults(filter) {
  const filterRef = useRef(filter)
  useEffect(() => { filterRef.current = filter }, [filter])   // keep fresh

  useEffect(() => {
    const ws = new WebSocket(URL)
    ws.onmessage = (e) => {
      const row = JSON.parse(e.data)
      if (row.topic === filterRef.current) add(row)   // always current
    }
    return () => ws.close()
  }, [])          // socket created once, and that is deliberate
}

// This "latest ref" pattern is the standard answer for callbacks
// that must not re-subscribe: analytics handlers, event listeners
// registered once, and third-party library callbacks.

// It is an escape hatch. Reach for it when re-running the effect
// would be genuinely wasteful — not to avoid fixing dependencies.

Reading State Right After Setting It

The related timing question, and what to do instead of waiting.

Use the value you computed, not the state
function handleSave() {
  setSaved(true)
  console.log(saved)        // false — this render's snapshot
  if (saved) doThing()      // never runs on the first click
}

// Use the value you already have
function handleSave() {
  const next = true
  setSaved(next)
  if (next) doThing()
}

// Or react to it in an effect, if it is genuinely about the new render
useEffect(() => { if (saved) doThing() }, [saved])

// flushSync forces a synchronous re-render and DOM update. It exists
// for cases like scrolling to a row you just added:
flushSync(() => setRows(r => [...r, newRow]))
listRef.current.lastChild.scrollIntoView()
// It disables batching, so use it only where you need the DOM updated
// before the next line runs.

Key Points to Remember

  • 1Every render creates new callbacks closing over that render's props and state
  • 2A callback that outlives its render — in a timer, listener or subscription — keeps stale values
  • 3The functional updater form removes the dependency on the captured value entirely
  • 4The latest-ref pattern keeps a long-lived callback reading current values without re-subscribing
  • 5State read immediately after setting it is still the old snapshot; use the value you computed, or flushSync when the DOM must update first

Interview Questions

Sign in to ask Aria
1

Why does a counter inside setInterval get stuck after one increment?

Hard
2

What is the latest-ref pattern and when do you need it?

Hard
3

Why does reading state immediately after calling its setter give the old value?

Medium

Ask Aria about Stale Closures and Update Timing

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…