Stale closures, refs and useEffectEvent
An effect's callback keeps the values of the render that set it up; fresh values cost a new subscription or a non-reactive read.
Predict first
Before you read: predict
ExamHall candidate app. Every 10 s the app sends the proctor console a heartbeat with the question on screen. A reviewer asked for the heartbeat to report the current question without restarting its interval, so a teammate read it through a ref:
The component mounts at 0 s on Q1. The candidate moves to Q2 at 4 s and to Q3 at 13 s.
Which heartbeats reach the proctor console in the first 20 s?
function QuestionHeartbeat() { const [questionId, setQuestionId] = useState('Q1'); const latest = useRef(questionId); // added in review useEffect(() => { const id = setInterval(() => heartbeat(latest.current), 10000); return () => clearInterval(id); }, []); return <QuestionNav current={questionId} onGo={setQuestionId} />;}The 30-second version
With [], a callback keeps its first render's values. Listing a value refreshes them by replacing the subscription, and a replaced interval counts a full period. Non-reactive reads avoid that: an updater computes from pending state, an Effect Event sees the latest render, and a ref keeps its first value until code writes .current.
An effect's callback keeps the values of the render that set it up; fresh values cost a new subscription or a non-reactive read.
How it works
Step 1 of 10
In the kitchen
- The card written at kickoff → The values the effect's callback closed over (step 1)
Analogy: A substitute's instruction card (sport: football touchline)
At kickoff the coach hands a substitute a card, "0–0: keep pressing", and sends him on. Every five minutes he acts on what the card says.
| In the analogy | In React |
|---|---|
| The card written at kickoff | The values the effect's callback closed over (step 1) |
| After two goals he still presses, because the card still says 0–0 | A [] interval reading the first render's state (step 2) |
| Each goal sends on a fresh player whose five-minute routine starts from zero; with a goal every two minutes, nobody ever acts | The cost of listing a value: every change restarts the interval's count (step 4) |
| A card that says "one step beyond wherever you stand now" | An updater: works from the current value, needs no new card (step 5) |
| He glances at the stadium scoreboard each time he acts | A ref or Effect Event: read at fire time (steps 7–9) |
Breaks down when:
- A scoreboard updates itself; a ref changes only when your code writes it. Wrong inference: "a ref follows state automatically."
- Anyone may read a scoreboard; an Effect Event may be called only from Effects or other Effect Events, not during render or by a child. Wrong inference: "pass it down as a prop."
Contrasting case: the coach shouting one instruction when the ball goes out. That is a click handler: React creates it in each render, and the handler that runs belongs to the render the candidate is looking at.
In real terms: an effect's callbacks read the render that set them up; re-running the effect refreshes them by replacing the subscription, while refs and Effect Events refresh the read instead.
Examples, run for real
Example 1: the ExamHall countdown, three ways
The exam starts at 90:00 (5400 s). Each version runs 5 virtual seconds.
useEffect(() => {
const id = setInterval(() => setSecondsLeft(secondsLeft - 1), 1000); // A and B
// C: setInterval(() => setSecondsLeft((s) => s - 1), 1000)
return () => clearInterval(id);
}, []); // B: [secondsLeft]
A [] + setSecondsLeft(secondsLeft - 1) after 5 s: 89:59 | interval setups=1 cleanups=0
B [secondsLeft] + setSecondsLeft(secondsLeft - 1) after 5 s: 89:55 | interval setups=6 cleanups=5
C [] + setSecondsLeft(s => s - 1) after 5 s: 89:55 | interval setups=1 cleanups=0
A is stuck: every tick computes 5400 − 1 (step 2). B is right but replaces its interval on every tick (steps 3–4). C is right with one interval, because the updater reads pending state (step 5).
Example 2: EXH-469, the backup that sends an empty answer
The 5 s backup had [] and called backup(answer), so it sent the first render's empty answer. MR !793 reads the answer through an Effect Event. The candidate types "Ohm" at 2 s and "Ohm's law" at 7 s.
const onBackup = useEffectEvent(() => backup(answer)); // MR !793
useEffect(() => {
const id = setInterval(() => onBackup(), 5000);
return () => clearInterval(id);
}, []); // onBackup is not listed
// ref variant: const latest = useRef(answer); useEffect(() => { latest.current = answer; }); … backup(latest.current)
// NOT variant: the same ref without the effect that writes it
BUG [] deps, reads answer backups: 5s:"" 10s:"" | interval setups=1
FIX useEffectEvent (MR !793) backups: 5s:"Ohm" 10s:"Ohm's law" | interval setups=1
ALSO ref written in an effect backups: 5s:"Ohm" 10s:"Ohm's law" | interval setups=1
NOT useRef(answer), never written backups: 5s:"" 10s:"" | interval setups=1
An updater could not help here: the backup sends data, it does not compute state (step 6). The Effect Event and the written ref both read the latest answer with one interval (steps 8–9). The unwritten ref keeps useRef's first value, the empty answer, because nothing assigns .current (step 7).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
(p1-chat-room-event.mjs, 50 runs, 1 distinct output; also 50 at 8 parallel)
A chat room reads the theme through an Effect Event and leaves the effect's dependencies empty: const onMessage = useEffectEvent((m) => toast(m, theme)); useEffect(() => { const conn = connect(roomId); conn.on('message', (m) => onMessage(m)); return () => conn.close(); }, []);. It mounts in room general with theme light. At 1 s the theme turns dark; at 2 s message m1 arrives in general; at 3 s the user switches to room exams; at 4 s message m2 arrives in exams.
Which toasts does the user see?
Question 2 of 5
(p2-streaming-report.mjs, 50 runs, 1 distinct output; also 50 at 8 parallel)
A player reports quality every 10 s from an interval. Quality changes from 720p to 1080p at 15 s. A: the effect lists [quality] · B: the interval calls onReport, where onReport = useEffectEvent(() => send(quality)) · C: the interval sends latest.current, written in an effect after each render.
Which versions report 1080p at 20 s and set the interval up only once?
Question 3 of 5
(p3-queue-report.mjs, 50 runs, 1 distinct output; also 50 at 8 parallel)
A ticket waiting room counts the queue position down from 120 with setPosition((p) => p - 1) in a [] interval every 3 s. A second interval must report the position on screen every 5 s. The app runs in development under <StrictMode>. A: a [] interval calls setPosition((p) => { report(p); return p; }) · B: a [] interval calls onReport, where onReport = useEffectEvent(() => report(position)) · C: the report effect lists [position] and calls report(position) · D: a [] interval calls report(position).
Which report versions send exactly one report at 5 s and one at 10 s, each equal to the position on screen, within React's rules?
Question 4 of 5
A waiting room counts freed seats. One socket message can free several seats at once, and the callback counts each one:
It mounts with free 0. One message frees C1, C2 and C3; a second message frees D4 and D5.
Which version shows free 5 and keeps a single connection?
const [free, setFree] = useState(0);useEffect(() => { const conn = socket.connect(); conn.on((seats) => seats.forEach(() => { /* count one seat */ })); return () => conn.close();}, [/* dependencies */]);Question 5 of 5
A watch tracker should report the playback position every 5 s through a small custom Hook:
In 20 s of playback no ping is sent.
Which single change sends a ping every 5 s with the position at that moment?
function useInterval(callback, ms) { useEffect(() => { const id = setInterval(callback, ms); return () => clearInterval(id); }, [callback, ms]);}function WatchTracker({ position }) { // re-renders every 250 ms as playback moves useInterval(() => sendPing(position), 5000); return <progress value={position} />;}Go deeper
Pro tips
- Test timer bugs on a virtual clock, not with real waits.
- When it helps: any interval or timeout that reads state (steps 2–4); real waits make counts depend on timer drift.
- How: in Node 24,
mock.timers.enable({ apis: ['setInterval'] })fromnode:test, render insideact(), change the value, then advance past one period inact(() => mock.timers.tick(ms))and assert the payload. - Status: RAN (React 19.3.0, Node 24.19.0). Jest's and Vitest's fake timers are the usual equivalents; they were not exercised here.
- Upgrade
eslint-plugin-react-hookswhen you adoptuseEffectEvent.- When it helps: an older plugin may ask you to list the Effect Event (step 9). React's 19.2 post says to upgrade to
eslint-plugin-react-hooks@latest"so that the linter doesn't try to insert them as dependencies". - Status: DOC (react.dev, fetched 2026-09-27).
- Counting setups and cleanups, and keeping
exhaustive-depson, arereact-effects-synchronizationtips 1 and 2; they are not repeated here.
- When it helps: an older plugin may ask you to list the Effect Event (step 9). React's 19.2 post says to upgrade to
Sources
- react.dev,
useEffectEventreference: https://react.dev/reference/react/useEffectEvent. Covers: "Effect Events can only be called from inside Effects or other Effect Events"; not during rendering, not passed to other components or Hooks; not a way to avoid specifying dependencies; identity changes on every render; Effect Events "always 'see' the latest values". Fetched and confirmed; valid as of 2026-09-27. The page states no version. - react.dev blog, "React 19.2" (2025-10-01): https://react.dev/blog/2025/10/01/react-19-2.
useEffectEventwas introduced in the stable 19.2 release (listed under "New React Features"); Effect Events "should not be declared in the dependency array". Fetched 2026-09-27. React 19.3.0 exports it (executed:typeof React.useEffectEvent === 'function'). - react.dev,
useRefreference: https://react.dev/reference/react/useRef.initialValue: "This argument is ignored after the initial render"; read or write refs from event handlers or effects, not during rendering. Fetched 2026-09-27. - react.dev, "Separating Events from Effects": https://react.dev/learn/separating-events-from-effects. Reactive vs non-reactive logic; a dependency change re-runs the Effect. Fetched 2026-09-27.
- react.dev, "Referencing Values with Refs": https://react.dev/learn/referencing-values-with-refs. "Changing a ref does not!" re-render; do not read or write
ref.currentduring rendering. Fetched 2026-09-27. - react.dev,
useStatereference: https://react.dev/reference/react/useState. The updater queue; an updater "must be pure", and Strict Mode calls it twice. Fetched 2026-09-27. - react.dev, "Synchronizing with Effects": https://react.dev/learn/synchronizing-with-effects. In development React remounts each component once (setup, cleanup, setup). Fetched 2026-09-27.
Related lessons
Checked against the tools and docs as of 2026-09-27.