useEffectEvent
: Goodbye to stale closure headachesReact 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.
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 useRef
Before 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.
useEffectEvent
To 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.useEffectEvent
import { 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>
Shadcn CLI 3.0 takes component management to a new level. With namespaced registries, private access, and AI-powered discovery, it’s now faster and smarter to build React UIs.
Zod’s flexibility comes at a cost. This article breaks down why Zod is slower than AOT-compiled validators like Typia, and how to fix it with a build-time optimization that brings production-grade performance.
Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 15th issue.
Jemima Abu examines where AI falls short on accessibility and how we can best harness AI while still building products that everyone can use.
Hey there, want to help make our blog better?
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