React 19.2's stable useEffectEvent ends the useEffect that reconnects your socket on every unrelated state change. The before/after and the gotchas.
You have a chat component. It connects to a room, and when the socket says "connected" you pop a toast — unless the user has muted notifications. Simple. Then a teammate files a bug: every time someone toggles dark mode or flips the mute switch, the socket tears down and reconnects. Messages drop for a second. You stare at the effect, and there it is: theme and isMuted are in the dependency array, so React re-runs the whole effect when either changes. React 19.2 ships a stable fix for exactly this class of bug, and if you're on React you'll hit it this week.
The hook is useEffectEvent. It went stable in 19.2, and the matching eslint-plugin-react-hooks support is now the default in most toolchains, which is the real reason it's worth your attention now rather than six months ago — the linter finally stops fighting you over it.
The exhaustive-deps rule isn't being pedantic. An effect closes over the props and state it reads. If you read theme inside the effect but leave it out of the deps, the effect keeps a stale theme from whatever render first created it. That's the classic stale-closure bug, and the lint rule exists to stop it. So the linter is right: if your effect reads isMuted, isMuted belongs in the array.
The trouble is that the dependency array does two jobs at once, and we only want one of them. It says "keep these values fresh" and "re-run the effect whenever they change." For the socket connection, roomId genuinely should trigger a reconnect. But theme and isMuted are values you want to read at the moment of connection — you don't want them to cause a reconnection. There was no clean way to express that split. People reached for refs, custom useEventCallback hooks, or just disabled the lint rule and prayed.
import { useEffect } from 'react';
function ChatRoom({ roomId, theme, isMuted }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
if (!isMuted) {
showToast('Connected!', theme);
}
});
connection.connect();
return () => connection.disconnect();
// linter demands theme and isMuted here, and it's not wrong
}, [roomId, theme, isMuted]);
}
Honest options before 19.2 were all bad. Add theme and isMuted and accept spurious reconnects. Drop them and accept stale values plus a disabled lint line. Or hand-roll a ref that you write to on every render and read inside the effect — which works but is boilerplate you re-explain to every code reviewer.
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme, isMuted }) {
const onConnected = useEffectEvent(() => {
if (!isMuted) {
showToast('Connected!', theme);
}
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
That's the whole fix. The non-reactive logic — "show a toast with the current theme unless muted" — moves into an Effect Event. The effect now depends only on roomId, so the socket reconnects when and only when the room changes. Inside onConnected, theme and isMuted are always the latest values, because Effect Events read fresh props and state at call time, not at the time the effect was set up.
An Effect Event is deliberately not stable. Its identity changes on every render — the opposite of useCallback. That sounds like it would break the dependency array, but it's the point. The linter knows Effect Events are special and refuses to let you add onConnected to the deps array at all. React keeps an internal pointer to the latest version of the function, so when your effect calls onConnected(), it invokes the most recent closure, which sees the most recent theme and isMuted.
This is why you must be on a current eslint-plugin-react-hooks. Older versions don't recognize the hook and will either try to push it into your dependency array or flag your fresh reads as missing deps. Upgrade the plugin before you migrate, or you'll spend an afternoon arguing with red squiggles that are technically out of date.
npm install --save-dev eslint-plugin-react-hooks@latest
Here's the part you only learn after using it on a real codebase. The temptation is to treat useEffectEvent as a way to make the exhaustive-deps warning disappear. It is not. If you wrap genuinely reactive logic in an Effect Event just to shrink your dependency array, you've reintroduced the stale-closure bug the linter was protecting you from — you've just hidden it behind a hook that the linter trusts.
The test I use: ask whether the value should cause the effect to re-run. If yes, it's a real dependency — leave it in the array. If the value is something you only want to read at the moment the effect does its thing, it belongs in an Effect Event. In the chat example, roomId passes the first test (new room means reconnect) and isMuted passes the second (read it when connecting, don't reconnect on toggle). When you can't articulate which bucket a value falls into, that's a signal your effect is doing too much, not that you need another Effect Event.
Two constraints aren't optional, and the docs are quiet enough about them that people trip anyway.
First, an Effect Event can only be called from inside an effect in the same component or custom hook where it's declared. You cannot pass onConnected down as a prop to a child, and you cannot hand it to another hook. The moment you try, you've broken the model — the event is tied to the component that owns it. If you find yourself wanting to pass one around, you actually want a regular callback, probably memoized with useCallback.
// Don't do this — Effect Events are not portable
function ChatRoom({ roomId }) {
const onConnected = useEffectEvent(() => { /* ... */ });
return <Toolbar onPing={onConnected} />; // breaks the contract
}
Second, don't call an Effect Event during render or read its return value as if it were a normal computation. It's meant to be fired from inside an effect, an event handler invoked by an effect, or a timer the effect sets up. Calling it in the render path defeats the freshness guarantee and the linter will flag it.
If your codebase has a hand-rolled useEventCallback — the ref-plus-useCallback pattern that's been copied around React projects for years — useEffectEvent replaces most uses of it. The ref version looked like this:
function useEventCallback(fn) {
const ref = useRef(fn);
useLayoutEffect(() => { ref.current = fn; });
return useCallback((...args) => ref.current(...args), []);
}
It works, but it returns a stable function, which means people sometimes pass it to children and depend on its identity. Effect Events intentionally don't give you that, which is a feature: it stops people from using a cross-render-stable callback in places where they should be thinking harder about reactivity. If you genuinely need a stable callback to pass to a memoized child, keep useCallback. If you need fresh reads inside an effect without re-running it, reach for useEffectEvent. They solve different problems and the overlap is smaller than it looks.
Sockets aren't the only place this shows up. Picture a dashboard that polls an endpoint every ten seconds and logs an analytics event with the user's current filters each time it refreshes. The naive version puts filters in the dependency array, so every keystroke in the filter box tears down and recreates the interval — your poll cadence resets, and on a fast typist you might never actually hit the ten-second mark.
function Dashboard({ filters }) {
const onPoll = useEffectEvent(() => {
fetchData(filters);
track('dashboard_refresh', { filters });
});
useEffect(() => {
const id = setInterval(() => onPoll(), 10000);
return () => clearInterval(id);
}, []); // interval set up once, always reads the latest filters
}
The interval is created exactly once. Each tick reads the current filters because the work lives in an Effect Event. This is the shape that used to force the ref dance, and it's the clearest win for the hook: setup that should happen once, paired with logic that needs fresh data on every fire.
You don't need a big refactor. Walk your effects and look for dependency arrays where some entries trigger expensive teardown you don't want — socket reconnects, subscription churn, re-fetches, analytics that double-fire. For each, split the array into "values that should re-run this effect" and "values I just want to read." Move the second group's usage into an Effect Event. The diff is small per effect, and the payoff is that your reconnect, your subscription, and your setInterval stop firing on unrelated state changes.
One caveat before you go all in: useEffectEvent is stable in 19.2, but if you're stuck on an older React 19 minor or still on 18, the hook isn't there and the older canary spelling (experimental_useEffectEvent) behaved differently. Pin your React version and your lint plugin together, run your test suite, and check that the effects you touched actually stopped over-firing — a quick console log in the cleanup function is the fastest way to confirm a reconnect is no longer happening. It's the kind of fix that's easy to get right and easy to half-apply, so verify rather than assume.