
React 19.2 useEffectEvent: Kill Stale Closures in Effects
Summary
Master useEffectEvent in React 19.2 to separate non-reactive logic from your Effects.
If you have ever stared at a React Effect that logs the wrong value or tears down a perfectly good websocket every time the user toggles dark mode, you have met the stale closure. useEffectEvent — stable in React 19.2 — is the official fix. It lets you read the latest props and state from inside an Effect without making them dependencies.
This guide walks through the problem, the workarounds people have been using for years (refs, ESLint disables, manual sync), and how useEffectEvent replaces all of them. By the end you will know exactly when to reach for it, when not to, and how to refactor an Effect that is fighting you today.
Prerequisites
- React 19.2 or newer (or Next.js 16, which ships with the matching React canary).
- Comfortable with
useEffectand the dependency array. - A passing familiarity with closures in JavaScript.
The stale closure, in 12 lines
Here is the canonical bug. We want to log the current count once a second:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
Click the button five times and the console prints 0, 0, 0, 0, 0. The interval was set up on the first render, when count was 0, and the closure captured that value forever. Adding [count] to the deps fixes the log but creates a new interval every click — almost certainly not what you want.
Real apps hit this constantly: an analytics call inside an Effect that reads the latest user, a websocket subscription that wants the latest filters, a chat client that should pick up the latest theme when it shows a toast. The reactive part (room id, user id, connection url) is different from the non-reactive part ("whatever the value happens to be when this fires"). Until 19.2, the language did not have a clean way to say so.
The workarounds you can stop using
Three patterns dominated the pre-19.2 codebase. Each works, none feels good.
1. The ref pin
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
return () => clearInterval(id);
}, []);
Two Effects, a ref, and a synchronization step. It works but every reader has to mentally track the indirection.
2. The ESLint disable
Sprinkling // eslint-disable-next-line react-hooks/exhaustive-deps ships fast and breaks later. The next person who edits the Effect introduces a new reactive value, forgets the disabled rule is hiding it, and now the Effect silently misses updates.
3. The kitchen-sink dependency array
Listing everything fixes the closure but reruns the Effect on every unrelated change. A websocket connection that recreates itself when the theme toggles is a UX (and bandwidth) bug.
Enter useEffectEvent
useEffectEvent wraps a function so it always sees the latest props and state — but it is not reactive. Calling it from an Effect does not force the Effect to re-run, and the wrapped function does not belong in the dependency array. You import it from react:
import { useEffect, useEffectEvent, useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
console.log(count); // latest count, always
});
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, []); // onTick is NOT a dep
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
Click five times and the console prints 1, 2, 3, 4, 5. The interval is set up exactly once. The fact that onTick reads count is its problem, not the Effect's.
A real refactor: chat client that respects theme
Here is a more realistic Effect. It opens a chat connection for roomId and shows a toast when a message arrives. The toast should match the current theme, but the connection itself must not reset every time the user toggles light and dark.
Before
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const conn = createConnection(roomId);
conn.on('message', (msg) => {
showToast(msg, { theme }); // closes over theme
});
conn.connect();
return () => conn.disconnect();
}, [roomId, theme]); // theme reconnects: bad
}
Drop theme from the deps and the toast color drifts. Keep it and you reconnect every theme toggle. This is exactly the situation useEffectEvent was designed for.
After
function ChatRoom({ roomId, theme }) {
const onMessage = useEffectEvent((msg) => {
showToast(msg, { theme }); // always latest theme
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('message', onMessage);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // theme is gone, on purpose
}
Now the connection only resets when roomId changes. The toast picks up whatever theme is current at the moment a message lands. The dependency array tells the truth: only roomId is a reactive input to the connection.
A second example: analytics that always knows the user
Analytics calls are the other place this hook earns its keep. Imagine a page-view ping that should fire once per route, attaching the latest user id and feature flags. The route is reactive — a change means we want a new ping. The user id and flag set are non-reactive: a change in either should not fire a duplicate ping, but the next ping should pick them up.
function PageTracker({ pathname, user, flags }) {
const sendPageView = useEffectEvent(() => {
analytics.track('page_view', {
pathname,
userId: user.id,
flags: Object.keys(flags).filter(k => flags[k]),
});
});
useEffect(() => {
sendPageView();
}, [pathname]); // only the path is reactive
return null;
}
Without useEffectEvent you had two bad options here. List user and flags in the deps and a flag toggle fires a phantom page view. Leave them out and your dashboards show stale flag values for users who toggle settings mid-session. Now the Effect describes the reality: the page only changes when the path changes, and the metadata is whatever it is at that moment.
The mental model
Read reactive as "this Effect should run again when this value changes" and non-reactive as "this Effect should see the latest value, but a change alone is not a reason to run again." Reactive values go in the dependency array. Non-reactive reads go inside a useEffectEvent.
If you can describe a value with the phrase "whatever it happens to be when the callback fires," it is non-reactive. The classic examples: the current user for an analytics ping, the current filters for a logged search, the current locale for a formatted toast.
How it works under the hood
An Effect Event is, mechanically, a function that React holds onto in a ref and swaps the implementation of on every render. The reference you get back is stable — the same identity render after render — but the body it points to is always the latest one. That is why it can read the latest props and state without participating in the dependency graph: the function React installs after each commit closes over the new values, while the wrapper your Effect captured points at the same slot.
This also explains the in-Effect-only restriction. During render, React has not yet committed the new state, so the slot still holds the previous body. Calling an Effect Event then would give you the old values, which is the opposite of the contract. The render-time error is there to keep that contract honest.
If you have written your own version of this in the past — a useLatest or useEvent hook backed by a ref and a layout effect — the official version does the same job, plus it is recognized by the lint rule and the React DevTools. The custom hook still works, but the official one composes correctly with concurrent rendering and Strict Mode's double-invoke.
When not to reach for this hook
Not every dependency-array friction is a stale closure. A few cases look similar but want a different fix.
- The value really is reactive. If a change should re-run the Effect, it belongs in the deps.
useEffectEventis for the values that should not. - The work belongs in an event handler. If a button click triggers a fetch and you are reaching for an Effect to read the latest state, the fetch probably belongs directly in
onClick. No Effect, no closure problem. - You want to memoize a computed value. That is what
useMemois for. Effect Events return a callback, not a value, and they cannot run during render. - You need a stable callback for a child. Children re-render on prop identity, and Effect Events are not legal to pass down. Wrap with
useCallbackor hoist the function.
Pitfalls and gotchas
- Only call Effect Events from Effects. Calling them from event handlers, render, or passing them to children is unsupported and will warn. If you need a stable callback for a child component, use
useCallbackor pass the wrapped function through a ref. - Do not put them in the dependency array. The whole point is that they do not need to be there. The lint rule for
useEffectEventknows this; older configs may complain — upgradeeslint-plugin-react-hooks. - They are not setState replacements. If you find yourself calling an Effect Event during render to compute a value, you want a normal function or
useMemo, not this hook. - They cannot run during render. Effect Events are guaranteed to see the latest committed state, which means they cannot fire while React is still figuring out what that state is. Calling one in render throws.
- Refactor first, wrap second. Sometimes the right fix is to move the non-reactive read out of the Effect entirely — into the event handler that actually triggered the work. Reach for
useEffectEventwhen the read genuinely belongs inside the Effect's lifecycle.
Quick reference
| Situation | Use | Why |
|---|---|---|
| Effect should re-run when the value changes | Dependency array | That is what reactive means |
| Effect should read the value but not re-run on changes | useEffectEvent | Latest value, no re-run |
| Stable callback passed to a child component | useCallback | useEffectEvent is Effect-only |
| Mutable holder shared across renders | useRef | Still the right tool for non-state mutables |
| Memoize a derived value for render | useMemo | Effect Events do not run during render |
Next steps
- Audit any
// eslint-disable-next-line react-hooks/exhaustive-depsin your codebase — most of them areuseEffectEventcandidates. - Pair with the new
<Activity />component for screens that should keep state when hidden. Effect Events fire on the latest committed state even after an Activity becomes visible again. - Read the official RFC and API reference at react.dev for the exact semantics around Strict Mode and concurrent rendering.
Stale closures used to be a rite of passage. With useEffectEvent stable in 19.2, that part of the React learning curve is finally flatter. Pick one Effect in your app today, find the value that does not belong in the dependency array, and move it into an Effect Event. The diff will be small, the bug surface smaller.
Comments
Be the first to comment