Dealing with Stale State

Every function inside a component — event handlers, effects, callbacks handed to setTimeout — closes over the props and state of the render that created it. That is not a bug; it is what makes a render a consistent snapshot. It surprises people when a callback fires much later and reports a value the user has already changed.

A delayed callback sees old state, but the latest refHTML
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>
<div id="root"></div><script type="text/babel" data-type="module">
  import React, { useRef, useState, useEffect } from 'react';
  import { createRoot } from 'react-dom/client';
  function Example() {
    const [count, setCount] = useState(0), [msg, setMsg] = useState('');
    const latest = useRef(count);
    useEffect(() => { latest.current = count; });        // synced after every commit
    const report = () =>
      setTimeout(() => setMsg(`state said ${count}, ref says ${latest.current}`), 500);
    return <div style={{font: '16px system-ui'}}><p>Clicked {count} times</p>
      <button onClick={() => setCount(c => c + 1)}>Click me</button>{' '}
      <button onClick={report}>Report in 0.5 s</button>
      <p><b>{msg}</b></p></div>;
  }
  createRoot(document.getElementById('root')).render(<Example />);
  const b = i => document.querySelectorAll('button')[i];
  setTimeout(() => { b(1).click(); b(0).click(); b(0).click(); b(0).click(); }, 400);
</script>
Browser output of Listing 2.48
Browser output of 48

The script presses "Report" while the counter is still 0, then clicks three times. Half a second later the callback runs with count frozen at 0 from the render that created it, while latest.current followed along and returns 3.

Which one you want depends on the question. For "what was on screen when the user pressed the button" — an analytics event, an undo snapshot — the closure is correct and a ref would be a bug. For "what is true right now" — a poll that must not restart, a handler registered once for the life of a subscription — keep the value in a ref updated by an effect, as above.

Two better answers exist for common cases. Use the updater form setCount(c => c + 1) when you only need the latest value to compute the next one; React 7,897 passes it in, so no closure is involved. For an Effect that must read fresh values without re-running, use useEffectEvent (Separating Events from Effects), which spares you a shadow copy of your own state.