A Remedy for useEffect
useEffect was never a generic reactivity tool, but a mechanism for synchronizing with external systems. useEffectEvent, as a helper, solves a specific problem — but it doesn’t redefine useEffect’s role.
React 19.2 finally shipped useEffectEvent. The idea is that the new hook improves useEffect, so it is very welcome. However, it’s important to understand exactly what it solves so we don’t repeat mistakes. When useEffect was released, many pointed out that it was a problematic API, because a good API needs to make its intent clear to the one who consumes it. That wasn’t the case.
Among all the problems that appeared, I believe the confusion that caused the greatest damage was at a higher level of abstraction. It came from the mistake of thinking that effect served to implement any reactivity in a declarative way — in React’s early days, the documentation sold it as a declarative UI library — and people didn’t hesitate to generalize concepts that should be applied with attention to each case’s nuances. Declarative programming was applied to solve any kind of problem, even when those cases required imperative solutions by their very nature.

Many understood that useEffect was a tool provided for that. So, if I have a state “A” that must change whenever state “B” changes, I implement that logic inside an effect and that’s it. It seems obvious, but from that point on, any variation of the type was put inside these hooks, even when they didn’t need to or shouldn’t be there (they should be in event handlers, for example). The result was endless tangled and inconsistent state management logic, unnecessary rerenderings, etc.
When the problems started to show up, there was an attempt to clarify the correct way to use it with linting, documentation, posts. Dan Abramov, for example, explained in his article A Complete Guide To useEffect, that:
…You should think of effects in a similar way.
useEffectlets you synchronize things outside of the React tree according to our props and state.
That pointed the community in the right direction. That is, useEffect must be used when I need to synchronize the application’s internal state with browser local storage, URL or theme changes, remote state (through HTTP calls), etc. All of these are elements that are outside the component tree and internal state management.
In addition, it was emphasized that event handlers were being underused. The same Abramov explained that if the state change comes from user interaction, that behavior should be managed inside an event handler, not inside an effect.
Around the same time, the official documentation published the article You Might Not Need an Effect, which reinforces these points.
Okay, but one problem remained
We understood when and how we should use the hook. However, an implementation flaw remained: all states used in the effect, called “dependencies” in this scope, should be declared in the dependency array. Every time a dependency varied its value (or reference, if it wasn’t a primitive), the effect would run, performing the proper synchronization. However, very often, the synchronization we implement inside the effect should be executed when one dependency changes, but not necessarily all of them.
Consider, for example, a chat room component where a useEffect is used to open a web socket connection. In the connection URL, you need to declare the roomId. Once the connection is open, a listener is declared that appends a new message to the list whenever it arrives.
function ChatRoom() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
};
return () => ws.close();
}, [roomId]);
return <>...</>;
}
Now let’s say that, in addition to adding the new message, we also want the component to update the unread messages counter if the new message arrives while the window isn’t active. For that, we add more state and update the effect.
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isWindowFocused, setIsWindowFocused] = useState(true);
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
if (!isWindowFocused) {
setUnreadCount((prev) => prev + 1);
}
};
return () => ws.close();
}, [roomId]); // The dependency array is incomplete!
... // Logic that manages the variation of isWindowFocused here
return <>...</>;
}
The effect implementation depends on the isWindowFocused state. When the onmessage listener implementation is declared, isWindowFocused carries the current value at the moment the effect runs, which is true. Every subsequent time onmessage runs, the value of isWindowFocused inside the listener’s implementation will always be the same, even if the component-level state is updated afterward. That’s the stale closure problem. To avoid it, the variable should be declared in the dependency array. Doing so, however, would imply running the effect not only when roomId changes value, but also when isWindowFocused does. Every time the user focuses the browser window, the web socket instance would be recreated, and the listener would be redefined with the new value, which is not the desired behavior.
Here comes useEffectEvent
The idea is that useEffectEvent solves precisely this kind of problem. In the dependency array of useEffect, you keep only the states that should actually trigger the effect for synchronization, and you implement in the callback passed to useEffectEvent all the logic that involves the other states used in the synchronization but that should not trigger it. The return of this hook is a function, or event, with the same implementation passed as an argument and that is applied inside the effect. Note that inside the effect, the states that serve as triggers can be passed to the event as arguments.
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isWindowFocused, setIsWindowFocused] = useState(true);
const incrementUnreadCount = useEffectEvent(() => {
if (!isWindowFocused) {
setUnreadCount((prev) => prev + 1);
}
})
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
incrementUnreadCount();
};
return () => ws.close();
}, [roomId]);
... // Logic that manages the variation of isWindowFocused here
return <>...</>;
}
What useEffectEvent does under the hood
I think it’s worth looking a bit into the implementation to understand why this works. Pointer logic is applied to stabilize the event reference and update its implementation in every rerendering cycle. Thus, all states used inside the event implementation will always be the most recent ones, solving the stale closure and isolating secondary states (dependencies that shouldn’t be triggers). This allows the effect to run only on the desired state variations.
The snippet below, taken from react-reconciler, shows one of the first levels of the useEffectEvent implementation and highlights the point where the reference (ref) is taken from memoized state. ref and the callback received as a parameter are passed to the next level in the stack by calling useEffectEventImpl.
function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = updateWorkInProgressHook();
const ref = hook.memoizedState;
useEffectEventImpl({ref, nextImpl: callback});
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
Later, ref, which points to the exposed function (impl), has its implementation replaced by the latest version (nextImpl), resulting from subsequent rendering.
if (eventPayloads !== null) {
for (let ii = 0; ii < eventPayloads.length; ii++) {
const { ref, nextImpl } = eventPayloads[ii];
ref.impl = nextImpl;
}
}
How useEffect changes
The useEffect used with useEffectEvent certainly becomes a better tool, because it now covers a wider range of possibilities that align with the hook’s original proposal without the need for hacks to make it work. For example, before it was possible to stabilize the reference of a function used inside the effect using a useRef and manually updating the implementation in .current. Now we have an official and more polished solution for doing so.
function Counter() {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);
// Stores the callback in a ref
const handleTickRef = useRef();
// Always updates ref.current with the fresh implementation
handleTickRef.current = () => {
console.log(`Count: ${count}, Multiplier: ${multiplier}`);
setCount(count + multiplier);
};
useEffect(() => {
const intervalId = setInterval(() => {
// Invokes the callback using the latest implementation version
handleTickRef.current();
}, 1000);
return () => clearInterval(intervalId);
}, []); // Dependency array can be empty and the effect runs only when the component is mounted
return (
<div>
<p>Count: {count}</p>
<p>Multiplier: {multiplier}</p>
<button onClick={() => setMultiplier(multiplier + 1)}>
Increase Multiplier
</button>
</div>
);
}
It’s important, however, not to lose sight of the intent behind the tool. The paradigm hasn’t changed. useEffect remains a hook designed to be used to synchronize internal state with external state. This update is a refinement, not a blank check for us to go back to using useEffect indiscriminately.