•5 min read

React useEffectEvent: Fixing Stale Closures Without Refs

Two weeks ago our order feed started duplicating events. A dozen a day, always on the busiest merchants. The component held a WebSocket keyed to merchantId and a handler that filtered on the current room. Someone dropped room from the dependency array to stop the socket reconnecting on every filter change. The socket stopped reconnecting, all right. The handler went on filtering with the room from the first render, so we kept appending events from rooms the user had already left.

That is a stale closure, and it is the React bug people most often cannot name while staring at it. useEffectEvent shipped in React 19.2 in October 2025 to deal with exactly this. If you have been carrying a useLatestRef helper between projects for years, this hook is the official version of it.

Why the callback is stuck in the past

Every render runs your component function again with its own props and state, and anything created during it keeps those values forever. An effect that runs once creates a callback that belongs to render number one. It never sees newer values.

function Countdown({ onDone }) {
  const [seconds, setSeconds] = useState(10)

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(seconds - 1)   // seconds is always 10 here
      if (seconds === 0) onDone()
    }, 1000)

    return () => clearInterval(id)
  }, [])
}

The timer shows 9, then sits there. setSeconds(9) keeps writing the same value, React bails out of the re-render, and the interval only ever calls the onDone from that first render. Nothing throws, which is what makes these expensive to find.

Close-up of source code on a dark monitor during a debugging session

The two fixes we had before, and what they cost

Functional updates solve the counting half. setSeconds(s => s - 1) asks React for the current value, so the callback no longer needs the captured one. It does nothing for onDone, or for any branch that reads a second piece of state. My feed was reading room to decide, so that fix never applied.

The other fix is the ref bridge. Keep a ref, write the latest callback into it, read the ref inside the effect:

const onDoneRef = useRef(onDone)

useEffect(() => {
  onDoneRef.current = onDone
}, [onDone])

useEffect(() => {
  const id = setInterval(() => {
    setSeconds((s) => Math.max(s - 1, 0))
  }, 1000)

  return () => clearInterval(id)
}, [])

useEffect(() => {
  if (seconds === 0) onDoneRef.current()
}, [seconds])

Three effects to express one countdown. It works, and it fails quietly: skip the syncing effect, or add a second value the callback needs and forget to sync that too, and you are back to stale data with no warning. Ours was exactly that omission. The compiler-powered rules in eslint-plugin-react-hooks v6 also flag ref writes during render, so assigning onDoneRef.current in the body is not the safe option anymore.

useEffectEvent splits the event out of the effect

Some of the code inside an effect is not effect logic at all. It is an event handler triggered by something external, and it should see the newest props and state without being a reason to rebuild a connection.

import { useEffect, useEffectEvent, useState } from 'react'

function OrderFeed({ merchantId, room }) {
  const [events, setEvents] = useState([])

  const onMessage = useEffectEvent((event) => {
    if (event.room !== room) return   // always the latest room
    setEvents((prev) => [...prev, event])
  })

  useEffect(() => {
    const ws = new WebSocket(`/ws/${merchantId}`)
    ws.onmessage = (e) => onMessage(JSON.parse(e.data))

    return () => ws.close()
  }, [merchantId])
}

Dependencies are honest now. merchantId is the only thing that should reconnect the socket, so it is the only thing listed. room changes still re-render the component, and the next message gets filtered with the new value. The ref indirection is gone, along with the class of bug where one ref out of three was never wired up. The same trick fixed a pointer listener that was re-attaching on every tool change.

The rules that will complain at you

Effect Events are not general purpose callbacks. You can call one from useEffect, useLayoutEffect, useInsertionEffect, or from another Effect Event in the same component, and it has to be declared in that same component or hook. Call it during render and you get a runtime error. Call it from a click handler and the linter complains.

Passing one to a child component is also rejected, which surprised me. Pass a normal prop, wrapped in useCallback if stability matters. I lost an hour trying to hand one down two levels.

Never put it in the dependency array. The identity changes on every render by design, and the linter has a dedicated message for it. If someone later adds it out of habit, the effect re-runs on every render and the mistake announces itself. You also need eslint-plugin-react-hooks v6, because older versions will add the Effect Event to your dependencies for you.

Where I do not reach for it

The temptation is to wrap anything that makes a dependency array noisy, and that is the failure mode. If a value changing means the effect should re-run, keep it in the deps. A page-view logger that reads the cart count is an event. A fetch that needs the current userId is effect logic, so userId stays in the array. And when the data lives outside React, a store or a media query, useSyncExternalStore is the right seam, not an effect that copies it into state.

If your project is not on 19.2 yet

The hook landed in React 19.2, so 18 and early 19 releases do not have it. The npm package use-effect-event provides the same API for those versions, which is enough to start migrating call sites before you upgrade. It is a shim though, and the real one ships with React.

Our fix was four lines: move the filter into one useEffectEvent, delete two refs, and remove a lint suppression comment from 2024. The duplicate orders stopped the same day. The before-and-after lives in Snippet Ark now, because I have explained this to three teammates in two weeks and pointing at working code beats pointing at a docs page.

Same app, other half of the story: effects firing twice in development, and the fix nobody reaches for. What StrictMode is actually telling you.