Skip to content

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?

Code for this question
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} />;}
Pick one

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

Setup closes over one render. Its interval or listener reads that render's props and state.
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

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

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?

Pick one

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?

Pick one

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?

Pick one

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?

Code for this question
const [free, setFree] = useState(0);useEffect(() => {  const conn = socket.connect();  conn.on((seats) => seats.forEach(() => { /* count one seat */ }));  return () => conn.close();}, [/* dependencies */]);
Pick one

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?

Code for this question
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} />;}
Pick one

Go deeper

Pro tips
  1. 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'] }) from node:test, render inside act(), change the value, then advance past one period in act(() => 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.
  2. Upgrade eslint-plugin-react-hooks when you adopt useEffectEvent.
    • 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-deps on, are react-effects-synchronization tips 1 and 2; they are not repeated here.
Sources
  • react.dev, useEffectEvent reference: 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. useEffectEvent was 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, useRef reference: 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.current during rendering. Fetched 2026-09-27.
  • react.dev, useState reference: 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.

Checked against the tools and docs as of 2026-09-27.