useEffectEvent
Extracts non-reactive logic from an Effect so it reads the latest props and state without re-running the Effect.
const onEvent = useEffectEvent(callback)- Added in React 19.2.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$callback | (...args: any[]) => any | A function containing the logic for your Effect Event. The function can accept any number of arguments and return any value. When you call the returned Effect Event function, the callback always accesses the latest committed values from render at the time of the call. |
Return value
(...args: any[]) => anyReturns an Effect Event function with the same type signature as your callback. You can call it inside useEffect, useLayoutEffect, useInsertionEffect, or from another Effect Event in the same component.
Errors
eslint-plugin-react-hooks reports an error if you call an Effect Event during rendering, list it in a dependency array, or pass it to another component or Hook.
Changelog
- PHP 19.2.0Released as a stable API. Before this it was available only in experimental and Canary builds, at times under the name useEvent.
- PHP 19.2.0Requires eslint-plugin-react-hooks@latest — older versions of the exhaustive-deps rule do not know that Effect Events must be omitted from the dependency array and will ask you to add them.
useEffectEvent splits an Effect into the part that should re-synchronize and the part that should not. The returned function always sees the latest committed props and state, so it is deliberately excluded from the dependency array — and because it is excluded, changing the values it reads no longer tears down and re-creates whatever the Effect set up.
When to reach for it
Whenever an Effect reads a value it should not react to. The stock example is a chat room that connects to roomId and shows a notification styled by theme: with theme in the dependency array, flipping to dark mode reconnects the socket; without it, the notification uses a stale theme. Move the notification into an Effect Event and both problems disappear. The same shape covers an interval that reads current state without restarting its timer, and an analytics call that logs the latest cart contents on navigation.
What trips people up
- It is not an escape hatch for silencing the linter. If you use it to hide a dependency the Effect genuinely should react to, you have converted a visible warning into a stale-data bug.
- Effect Events may only be called from inside Effects or other Effect Events in the same component — never during render, and never passed as a prop or to another Hook.
- Their identity intentionally changes on every render, so
useCallback-style memoization tricks will not work on them.
Example
import { useState, useEffect, useEffectEvent } from "react";
function createConnection(roomId) {
return {
connect() {},
disconnect() {},
on(event, handler) {
if (event === "connected") handler();
}
};
}
function showNotification(message, theme) {
console.log("[" + theme + "] " + message);
}
function ChatRoom({ roomId, theme }) {
// Reads the latest `theme` every time it is called, but is not a dependency.
const onConnected = useEffectEvent(() => {
showNotification("Connected to " + roomId + "!", theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme is deliberately absent — switching themes does not reconnect
return <p>Talking in {roomId} using the {theme} theme.</p>;
}
export default function App() {
const [theme, setTheme] = useState("light");
return (
<div>
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Toggle theme
</button>
<ChatRoom roomId="general" theme={theme} />
</div>
);
}When to use it
- Show a themed notification when a chat connects, without reconnecting every time the theme changes.
- Run an interval that reads the current state on each tick without resetting the timer on every state change.
- Log the latest cart contents to analytics on route change, keeping the Effect keyed only to the URL.
- Call the newest version of a callback prop from a subscription that must not be re-established.
Frequently asked questions
What React version do I need for useEffectEvent?
React 19.2 or later, where it shipped as a stable API; before that it existed only in experimental and Canary builds. Upgrade eslint-plugin-react-hooks to the latest version at the same time, because older releases of the exhaustive-deps rule do not know that Effect Events must be left out of the dependency array.
What is the difference between useEffectEvent and useCallback?
useCallback returns a memoized function with a stable identity that you can pass to children, and it captures the values from the render it was memoized in. useEffectEvent returns a function whose identity intentionally changes every render, which always reads the latest committed props and state and may only be called from inside an Effect — you cannot pass it to another component or Hook.
Can I use useEffectEvent to remove a dependency the linter is asking for?
No — that is the documented misuse. Ask whether the Effect should re-synchronize when the value changes: if it should, the value is reactive and belongs in the array; if it should not, and you are only reading the value while something else happens, that read belongs in an Effect Event. Using the Hook to silence a legitimate warning turns a visible lint error into a silent stale-data bug.
More examples
An interval that reads current state without restarting
Changing the step size takes effect on the very next tick, but the interval itself is never cleared and re-created, so the countdown does not restart mid-second. The two alternatives are both wrong: putting `increment` in the dependency array resets the timer on every click, and leaving it out without an Effect Event captures `increment` from the first render, so the count keeps advancing by 1 no matter what the button says.
import { useState, useEffect, useEffectEvent } from "react";
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
// Reads the latest `increment` on every tick.
const onTick = useEffectEvent(() => {
setCount((c) => c + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => clearInterval(id);
}, []); // the interval is created once and never re-created
return (
<div>
<p>Count: {count}</p>
<p>Step: {increment}</p>
<button onClick={() => setIncrement(increment + 1)}>Bigger step</button>
</div>
);
}Logging the latest data on navigation
Adding an item to the cart logs nothing; navigating logs exactly once, with the cart count as it stands at that moment. Note that the URL is passed as an argument rather than read from the closure — it is genuinely reactive, so it stays a dependency, while `cartItems` is read inside the Effect Event because a cart change is not a page visit. Getting that split right is the whole skill: arguments for what the Effect reacts to, closure reads for what it merely observes.
import { useState, useEffect, useEffectEvent } from "react";
function logVisit(url, itemCount) {
console.log("visited " + url + " with " + itemCount + " items in cart");
}
function Page({ url, cartItems }) {
const onVisit = useEffectEvent((visitedUrl) => {
// cartItems is read at call time; it is not a dependency of the Effect.
logVisit(visitedUrl, cartItems.length);
});
useEffect(() => {
onVisit(url);
}, [url]); // one log per navigation, never one per cart change
return (
<div>
<p>Page: {url}</p>
<p>Cart: {cartItems.length} items</p>
</div>
);
}
export default function Shop() {
const [url, setUrl] = useState("/shop");
const [cartItems, setCartItems] = useState([]);
return (
<div>
<button onClick={() => setUrl("/shop/shoes")}>Go to shoes</button>
<button onClick={() => setCartItems([...cartItems, "item"])}>Add to cart</button>
<Page url={url} cartItems={cartItems} />
</div>
);
}The misuse the docs warn about
Both components pass the exhaustive-deps rule, but only the second one works. The first shows the failure mode the documentation calls out explicitly: `useEffectEvent` was used to remove a dependency rather than to separate an event, so the connection is created once against the initial `roomId` and never re-synchronizes. The test to apply is behavioural, not lint-driven — ask whether the Effect should tear down and set up again when the value changes. If yes, it belongs in the dependency array.
import { useEffect, useEffectEvent } from "react";
function createConnection(roomId) {
return {
connect() {
console.log("connected to " + roomId);
},
disconnect() {
console.log("disconnected from " + roomId);
}
};
}
function ChatRoom({ roomId }) {
// WRONG: roomId is genuinely reactive. Hiding it here means the Effect
// never re-runs, so the app stays connected to the first room forever.
const connect = useEffectEvent(() => {
const connection = createConnection(roomId);
connection.connect();
});
useEffect(() => {
connect();
}, []); // linter is quiet, behaviour is broken
return <p>Room: {roomId}</p>;
}
function FixedChatRoom({ roomId }) {
// RIGHT: the Effect reacts to roomId; only non-reactive logic is extracted.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <p>Room: {roomId}</p>;
}
export default function App() {
return <FixedChatRoom roomId="general" />;
}
Discussion