useEffectEvent: stop lying to the dependency array
useEffectEvent: stop lying to the dependency array
🐰 TL;DR
useEffectforces you to choose: list every value you read, or lie to the linter.useEffectEventlets you read the latest prop or state from inside an Effect without making that Effect re-run.- Use it for side-effect logic that reacts to an event happening, not to a value changing.
- Shipped stable in React 19.2. Import it from
reactdirectly.
You've hit this warning before. You wrote a useEffect, you read a prop inside it, and the linter told you to add that prop to the dependency array. You added it, and now the Effect runs on every render. You removed it, and the linter screams. You reached for a ref, wrapped the ref in a useCallback, wrapped the callback in a useMemo, and by the end of it your component looked like a tax form.
There's a better primitive. It's called useEffectEvent, and it exists because the React team accepts that the dependency array was solving two problems with one answer.
The two kinds of values inside an Effect
Every value you read inside an Effect falls into one of two buckets.
Reactive values are the ones the Effect should respond to. If a roomId changes, you want to disconnect from the old chat room and connect to the new one. The Effect is about the room.
Latest values are the ones you just happen to need when the Effect fires. If you log an analytics event when the user visits a page, you want the latest userRole in that payload, but you do not want the analytics call to fire again every time userRole changes.
The dependency array treats both buckets the same. Everything you read becomes reactive, and everything reactive triggers re-runs. That's the source of the mess.
The workaround you've been writing
Here's the shape of the problem. You have a component that connects to a chat room and logs a visit event whenever the connection opens.
function ChatRoom({ roomId, userRole }) {
useEffect(() => {
const connection = createConnection(roomId)
connection.on("connected", () => {
logVisit(roomId, userRole)
})
connection.connect()
return () => connection.disconnect()
}, [roomId, userRole])
}
Read that dependency array. You want the Effect to re-run when roomId changes, because that's a new connection. You do not want it to re-run when userRole changes, because nothing about the connection has to change. But userRole is read inside the Effect, so the linter demands it, and now the connection tears down every time the user's role updates.
The classic fix is a ref.
function ChatRoom({ roomId, userRole }) {
const userRoleRef = useRef(userRole)
useEffect(() => {
userRoleRef.current = userRole
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on("connected", () => {
logVisit(roomId, userRoleRef.current)
})
connection.connect()
return () => connection.disconnect()
}, [roomId])
}
This works. It also requires two Effects, one ref, a comment explaining what the ref is for, and a quiet prayer that nobody will accidentally read userRole directly in the future and reintroduce the bug. You are fighting the linter by hiding the read.
useEffectEvent makes the bucket explicit
useEffectEvent lets you extract the non-reactive logic into its own function. That function always sees the latest props and state, and it does not count as a dependency.
import { useEffect, useEffectEvent } from "react"
function ChatRoom({ roomId, userRole }) {
const onConnected = useEffectEvent(() => {
logVisit(roomId, userRole)
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on("connected", () => {
onConnected()
})
connection.connect()
return () => connection.disconnect()
}, [roomId])
}
Read the dependency array now. It has roomId and nothing else, and the linter is fine with it. onConnected is not listed because useEffectEvent guarantees it's stable across renders. Inside onConnected, the closure captures the latest roomId and userRole at the moment the function is called, not at the moment the Effect ran.
You didn't need a ref. You didn't need two Effects. You didn't need to lie. You named the part of the logic that is "an event happening" and let React handle the rest.
The rule of thumb
Here's the way you want to think about this. Ask a question about every value inside your Effect: "if this value changes by itself, should the Effect tear down and set up again?"
- If yes, it's a dependency. Put it in the array.
- If no, it belongs inside a
useEffectEvent.
A chat connection should re-run on roomId. It should not re-run on userRole. That difference lives in your head, and until useEffectEvent, there was no syntactic way to write it down.
Where it actually earns its keep
Four patterns come up again and again in production code. useEffectEvent is the right answer for all of them.
1. Analytics and logging inside an Effect. You mount, you fire track("page_view", { userRole, plan }). You want the latest userRole in the payload. You don't want the page view to fire again when userRole changes, because that's not a new page view.
2. Callbacks from parent props. A parent passes onComplete to a child. The child runs an animation and calls onComplete when it finishes. If you depend on onComplete, the animation restarts every time the parent re-renders with a new function reference. Wrap the call in useEffectEvent and the animation runs once.
3. Values read only inside event handlers attached in the Effect. WebSocket onmessage, scroll listeners, IntersectionObserver callbacks — anything where the handler reads state when the event happens, not when the Effect mounts. You want the latest state at fire time. useEffectEvent gives you exactly that.
4. Feature flags read once per action. You connect to a socket, and when a message arrives you check a feature flag to decide how to parse it. The flag can flip between messages. You don't want the socket to reconnect when the flag flips — you just want the latest flag when the next message arrives.
What you must not do with it
useEffectEvent has one real rule, and breaking it is how you hurt yourself.
Do not call an Effect Event outside of an Effect. It is not a useCallback replacement. You cannot pass it to a child as a prop. You cannot call it from render-phase code. You cannot attach it directly to a DOM event (onClick={onConnected} is wrong). React enforces this at runtime, and the lint rule enforces it at write time.
The reason is about reactivity, not staleness. Effect Events are designed to hide the values they read from React's dependency tracking — that's the entire point of the primitive. If you call one during render, your render output ends up depending on values React isn't watching, and the whole reactivity model breaks. If you pass one as a prop to a child, the child's render ends up depending on values the child can't see. The hook exists to live inside an Effect and only inside an Effect.
If you need a stable callback for a DOM event, use useCallback. If you need a stable callback for a child component, lift the state or pass a primitive. useEffectEvent is specifically for the seam between a long-lived Effect and the logic that runs inside it.
A second example — the toast on save
Here's the pattern in its most common form. You save a form, and on success you want to show a toast that includes the current theme, the current locale, and the current user's name. You don't want to re-run the save Effect when the theme changes.
Before:
function SaveForm({ payload, theme, locale, userName }) {
useEffect(() => {
let cancelled = false
save(payload).then((result) => {
if (cancelled) return
showToast({
message: `Saved, ${userName}`,
theme,
locale,
})
})
return () => { cancelled = true }
}, [payload, theme, locale, userName])
}
Every theme switch retriggers the save. That's a bug, and the linter led you here.
After:
import { useEffect, useEffectEvent } from "react"
function SaveForm({ payload, theme, locale, userName }) {
const onSaved = useEffectEvent(() => {
showToast({
message: `Saved, ${userName}`,
theme,
locale,
})
})
useEffect(() => {
let cancelled = false
save(payload).then((result) => {
if (cancelled) return
onSaved()
})
return () => { cancelled = true }
}, [payload])
}
The save re-runs only when payload changes. The toast always reads the freshest theme, locale, and userName. The dependency array tells the truth.
Status
useEffectEvent ships stable in React 19.2. You import it from react directly, no experimental_ prefix, no aliasing. The react-hooks/exhaustive-deps rule already understands it and will stop asking you to list it as a dependency when you use it correctly. If you're on an older lint plugin, update it.
The verdict
Stop writing two Effects and a ref. If the value inside your Effect is something you want to read at fire time rather than react to, pull it into a useEffectEvent and list only the true reactive dependencies. The code gets shorter, the dependency array tells the truth, and the bug class where an unrelated prop tears down your connection goes away for good.
The dependency array was never wrong. It was overloaded. useEffectEvent takes the other half of the job and gives it a proper home.