Separating Events from Effects: Mastering useEffectEvent in React
In React, synchronizing components with external systems—such as browser event listeners, WebSocket channels, timers, or analytics loggers—relies heavily on the useEffect Hook (React documentation).
However, one fundamental tension has troubled React developers since Hooks were introduced: the conflict between reactivity and non-reactive logic inside Effects.
By design, Effects are reactive. According to the Rules of Hooks (React documentation), every prop, state, or function declared inside the component that is read by an Effect must be declared in its dependency array. If an Effect reads a prop, React forces the Effect to tear down and re-synchronize whenever that prop changes.
Yet in real-world applications, developers often need to read the latest value of a prop or state variable without wanting that value to trigger a re-synchronization. Historically, solving this required fragile useRef boilerplate or disabling the linter with // eslint-disable-next-line.
To solve this architectural dilemma cleanly, React introduced the useEffectEvent Hook (React documentation). In this guide, we examine why reactive Effects break when mixed with event-like logic, how useEffectEvent cleanly breaks the reactive chain, and the strict boundaries governing its usage.
The Dilemma: Why Effects Re-Synchronize Excessively
Consider a chat room component that connects to an external WebSocket service. When the connection establishes, the component displays a notification formatted with the user's selected UI theme:
// ⚠️ THE PROBLEM: theme triggers unwanted reconnects
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected to room!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // Both dependencies required!
}
The Conflict:
- The component should re-synchronize when
roomIdchanges (it needs to connect to the new room). - The component should NOT disconnect and reconnect when
themetoggles between'light'and'dark'. - But because
themeis read inside the Effect, the linter demandsthemein the dependency array. Toggling dark mode needlessly drops and reconnects the WebSocket!
The Legacy Workaround: The Fragile useRef Pattern
Before useEffectEvent, developers used useRef to store the latest value without adding a dependency:
// Clunky, imperative, and verbose
const themeRef = useRef(theme);
themeRef.current = theme;
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', themeRef.current);
});
// ...
}, [roomId]);
While this works, it introduces boilerplate, obscures intent, and is prone to synchronization bugs during concurrent rendering.
The Solution: useEffectEvent
useEffectEvent is a specialized Hook that extracts non-reactive logic out of your Effect (React documentation):
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
// 1. Declare an Effect Event for non-reactive logic
const onConnected = useEffectEvent(() => {
showNotification('Connected to room!', theme);
});
// 2. The Effect depends only on truly reactive inputs
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected(); // Reads latest `theme` automatically!
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ theme is NOT a dependency!
}
How It Works:
- Always Sees Latest State and Props: When
onConnected()is invoked, it always reads the current, up-to-date values oftheme, eliminating stale closure issues. - Non-Reactive: The function returned by
useEffectEventis not reactive. It does not trigger Effect re-runs, and it must not be added to the dependency array. - Breaks the Dependency Chain: You cleanly separate why the Effect runs (the
roomIdchanged) from what it does in response to an internal event (logging or showing a notification with the currenttheme).
Another Practical Example: Timer with Latest State
Another classic issue occurs with intervals and timers. Suppose you want an interval to log the user's current shopping cart size every 10 seconds:
function AutoLogger({ cartItems }: { cartItems: Item[] }) {
// Extract the non-reactive tick event
const onTick = useEffectEvent(() => {
logAnalytics('cart_heartbeat', { count: cartItems.length });
});
useEffect(() => {
const timer = setInterval(() => {
onTick();
}, 10000);
return () => clearInterval(timer);
}, []); // ✅ The interval timer is never reset when cart items change!
}
Without useEffectEvent, including cartItems in [cartItems] would reset the 10-second timer on every item added or removed, preventing the interval from ever completing if the user is actively adding items.
Strict Rules and Boundaries of useEffectEvent
Because Effect Events behave differently from standard functions, React enforces strict guardrails:
1. Never Call During Rendering
function BadComponent() {
const onClickEvent = useEffectEvent(() => { ... });
// ❌ FAILS: Cannot call Effect Events during rendering!
const result = onClickEvent();
}
Effect Events may only be called from inside useEffect, useLayoutEffect, useInsertionEffect, or from within other Effect Events in the same component. Calling them during rendering throws:
"A function wrapped in useEffectEvent can't be called during rendering".
2. Never Pass to Other Components or Hooks
// ❌ WRONG: Do not pass Effect Events as props
<ChildComponent onCustomEvent={onConnected} />
Effect Events are tied strictly to the lifecycle of the component where they are declared. To pass callbacks to child components, use standard props or event handlers.
3. Never Add to Dependency Arrays
The ESLint React Hooks plugin will explicitly warn if you include an Effect Event in a dependency array:
"Functions returned from useEffectEvent must not be included in the dependency array".
Conclusion
useEffectEvent resolves one of the longest-standing design friction points in React Hooks. By drawing a clear architectural boundary between reactive synchronization triggers and non-reactive event handlers inside Effects, it eliminates fragile useRef hacks, prevents stale closures, and ensures that external connections only reset when truly necessary.