useEffectEvent: Goodbye to stale closure headaches
React just released its third update for the year, React 19.2 – and with it, a stable version of useEffectEvent. This Hook is designed to improve how React handles effects, particularly by addressing the long-standing stale closure problem, a pain point that most React developers encounter daily.
The Hook allows developers to write effects that always have access to the latest props and state without triggering unwanted re-renders or manually syncing refs. It’s a small but powerful improvement that simplifies code, improves performance, and eliminates a common source of bugs in modern React applications.
In this article, we’ll look at why the useEffectEvent Hook is important, how it works, and how it compares to previous solutions.
For a deeper dive, check out our recap post for the React 19.2 release.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
useEffectEvent Hook?At its core, the useEffectEvent hook allows you to create stable event handlers within effects. These handlers always have access to the latest state and props, even without being included in the effect’s dependency array. However, on a broader scope, the Hook provides a solution to a subtle but common challenge that affects how effects in React handle the stale closure problem.
Traditionally, in React, when you write an effect like this:
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
….you’d expect the code to always log the latest value of count. But it doesn’t, and instead logs the value of the count variable from the initial render. This is because the callback closes over the old value of count, leading to what’s called a stale closure.
A common workaround has been to include all reactive variables (like count) in the dependency array:
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
While this fixes the stale closure, it causes the effect to re-subscribe on every state change, leading to unnecessary cleanup and setup cycles. This is especially problematic for event listeners, subscriptions, or animations.
Sometimes, the logic within an effect can include both reactive and non-reactive parts. Reactive logic, such as updating the count state from the previous example, needs to run whenever its value changes, whereas non-reactive logic only responds to explicit user interactions.
This can create tricky situations where code inside an effect re-runs even when it shouldn’t. For example, imagine a chat application with an effect that connects to a room and also displays a notification when the connection is established. If that notification depends on a theme prop, the effect would re-run every time the theme changes, even though reconnecting to the chat room isn’t necessary:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
Here, theme is a reactive dependency, which means switching themes will trigger reconnections. Ideally, the notification should update to reflect the latest theme, while the reconnection should depend only on the roomId, which changes only through explicit user action.
useEffectEvent solutionThe way useEffectEvent solves this issue is by extracting the non-reactive logic out of the effect:
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
Here, the non-reactive logic is moved from the effect into the callback inside useEffectEvent, defined as the onConnection Effect Event. It’s called an Effect Event because it behaves like an event handler that can only be invoked within an effect.
Unlike traditional event handlers that respond to user interactions, Effect Events are triggered imperatively. This means they run in response to changes within the effect, maintaining access to the latest props and state values without needing to be added as dependencies.
After separating the non-reactive logic, you can safely remove the theme prop from the dependency array, since it’s no longer directly used by the effect:
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
…
return () => … ;
}, [roomId]); // theme is no longer a dependency
As you can tell, this pattern goes against the standard rules of React, so it’s expected to trigger lint errors. However, with the updated ESLint plugin in React 19.2 and improved support for the useEffectEvent Hook, Effect Events are now ignored in dependency arrays.
This provides a clean, built-in solution to the problem of unnecessary re-runs caused by non-reactive logic within effects.
useEffectEvent vs useRefBefore useEffectEvent, a common pattern for addressing the stale closure issue was to use the useRef Hook to hold the latest value of a state or prop and update it on each render. Developers would assign the most recent state or prop value to a ref inside the effect. This way, callbacks could access up-to-date values through ref.current, even inside closures that were created once at mount time.
For example:
function Component() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // keep .current updated with latest count
useEffect(() => {
const id = setInterval(() => {
// Access latest count via ref, avoiding stale closure
console.log('Count:', countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
Here, the setInterval callback never encounters a stale closure over count because it always reads from countRef.current, which is continuously updated.
useRef approachWhile the useRef approach works, it’s a hacky workaround with several drawbacks:
.current property whenever the tracked state or prop changes. This adds boilerplate and increases the risk of forgetting to update.[]), which means it never re-runs or synchronizes when related data changes; only the .current reference updates. This can cause missed updates outside the ref scope.useEffectEvent.useEffectEvent improves upon this pattern by allowing developers to define non-reactive Effect Events that automatically capture the latest props and state, without triggering unwanted effect re-runs or requiring manual ref synchronization.
useEffectEventTo get the most out of this Hook and avoid common pitfalls, it’s important to understand its intended use cases and follow best practices:
useEffectEvent Hook is when you have a piece of code that needs to read the latest props or state, but shouldn’t cause the surrounding effect to re-run when those values change. useEffectEvent can encapsulate such logic and prevent common stale closure bugs without inflating dependency arrays.useEffectEvent lets you keep proper dependencies in your effects while isolating non-reactive logic and improving both reliability and maintainability.useEffectEvent. Only extract non-reactive logic that is conceptually event-like from effects. Reactive logic that requires dependency tracking should remain inside Effects.useEffectEventimport { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
function Page({ url }) {
const [cartItems, setCartItems] = useState(['apple', 'banana']);
// Define Effect Event that logs visits and includes latest cart items
const onVisit = useEffectEvent((visitedUrl) => {
console.log(`Visited ${visitedUrl} with ${cartItems.length} items in cart`);
});
useEffect(() => {
onVisit(url);
}, [url]);
}
}
The effect depends on url (since url is passed as an argument), so it re-runs only when the URL changes. However, inside the onVisit Effect Event, the cartItems state always accesses fresh data, even though changes to cartItems don’t trigger the effect to re-run. So, in a nutshell, passing reactive values as arguments to Effect Event functions explicitly controls which changes trigger the effect.
Whether you are managing simple side effects or handling complex event-driven logic in large applications, useEffectEvent streamlines side effect handling, making your code more predictable, easier to debug, and a step closer to fully aligning with the rules of React.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

Compare the top AI development tools and models of November 2025. View updated rankings, feature breakdowns, and find the best fit for you.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the November 5th issue.

A senior developer discusses how developer elitism breeds contempt and over-reliance on AI, and how you can avoid it in your own workplace.

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now