[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-react-useeffectevent-stale-closures":3},{"content":4,"lastModified":5},"\u003Cp>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 \u003Ccode>merchantId\u003C\u002Fcode> and a handler that filtered on the current \u003Ccode>room\u003C\u002Fcode>. Someone dropped \u003Ccode>room\u003C\u002Fcode> 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 \u003Ccode>room\u003C\u002Fcode> from the first render, so we kept appending events from rooms the user had already left.\u003C\u002Fp>\n\n\u003Cp>That is a stale closure, and it is the React bug people most often cannot name while staring at it. \u003Ccode>useEffectEvent\u003C\u002Fcode> shipped in React 19.2 in October 2025 to deal with exactly this. If you have been carrying a \u003Ccode>useLatestRef\u003C\u002Fcode> helper between projects for years, this hook is the official version of it.\u003C\u002Fp>\n\n\u003Ch2>Why the callback is stuck in the past\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">function Countdown({ onDone }) {\n  const [seconds, setSeconds] = useState(10)\n\n  useEffect(() =&gt; {\n    const id = setInterval(() =&gt; {\n      setSeconds(seconds - 1)   \u002F\u002F seconds is always 10 here\n      if (seconds === 0) onDone()\n    }, 1000)\n\n    return () =&gt; clearInterval(id)\n  }, [])\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The timer shows 9, then sits there. \u003Ccode>setSeconds(9)\u003C\u002Fcode> keeps writing the same value, React bails out of the re-render, and the interval only ever calls the \u003Ccode>onDone\u003C\u002Fcode> from that first render. Nothing throws, which is what makes these expensive to find.\u003C\u002Fp>\n\n\u003Cp>\u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F6424583\u002Fpexels-photo-6424583.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"Close-up of source code on a dark monitor during a debugging session\" loading=\"lazy\" width=\"1200\" height=\"800\">\u003C\u002Fp>\n\n\u003Ch2>The two fixes we had before, and what they cost\u003C\u002Fh2>\n\n\u003Cp>Functional updates solve the counting half. \u003Ccode>setSeconds(s =&gt; s - 1)\u003C\u002Fcode> asks React for the current value, so the callback no longer needs the captured one. It does nothing for \u003Ccode>onDone\u003C\u002Fcode>, or for any branch that reads a second piece of state. My feed was reading \u003Ccode>room\u003C\u002Fcode> to decide, so that fix never applied.\u003C\u002Fp>\n\n\u003Cp>The other fix is the ref bridge. Keep a ref, write the latest callback into it, read the ref inside the effect:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">const onDoneRef = useRef(onDone)\n\nuseEffect(() =&gt; {\n  onDoneRef.current = onDone\n}, [onDone])\n\nuseEffect(() =&gt; {\n  const id = setInterval(() =&gt; {\n    setSeconds((s) =&gt; Math.max(s - 1, 0))\n  }, 1000)\n\n  return () =&gt; clearInterval(id)\n}, [])\n\nuseEffect(() =&gt; {\n  if (seconds === 0) onDoneRef.current()\n}, [seconds])\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>eslint-plugin-react-hooks\u003C\u002Fcode> v6 also flag ref writes during render, so assigning \u003Ccode>onDoneRef.current\u003C\u002Fcode> in the body is not the safe option anymore.\u003C\u002Fp>\n\n\u003Ch2>useEffectEvent splits the event out of the effect\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">import { useEffect, useEffectEvent, useState } from 'react'\n\nfunction OrderFeed({ merchantId, room }) {\n  const [events, setEvents] = useState([])\n\n  const onMessage = useEffectEvent((event) =&gt; {\n    if (event.room !== room) return   \u002F\u002F always the latest room\n    setEvents((prev) =&gt; [...prev, event])\n  })\n\n  useEffect(() =&gt; {\n    const ws = new WebSocket(`\u002Fws\u002F${merchantId}`)\n    ws.onmessage = (e) =&gt; onMessage(JSON.parse(e.data))\n\n    return () =&gt; ws.close()\n  }, [merchantId])\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Dependencies are honest now. \u003Ccode>merchantId\u003C\u002Fcode> is the only thing that should reconnect the socket, so it is the only thing listed. \u003Ccode>room\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>The rules that will complain at you\u003C\u002Fh2>\n\n\u003Cp>Effect Events are not general purpose callbacks. You can call one from \u003Ccode>useEffect\u003C\u002Fcode>, \u003Ccode>useLayoutEffect\u003C\u002Fcode>, \u003Ccode>useInsertionEffect\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>Passing one to a child component is also rejected, which surprised me. Pass a normal prop, wrapped in \u003Ccode>useCallback\u003C\u002Fcode> if stability matters. I lost an hour trying to hand one down two levels.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>eslint-plugin-react-hooks\u003C\u002Fcode> v6, because older versions will add the Effect Event to your dependencies for you.\u003C\u002Fp>\n\n\u003Ch2>Where I do not reach for it\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>userId\u003C\u002Fcode> is effect logic, so \u003Ccode>userId\u003C\u002Fcode> stays in the array. And when the data lives outside React, a store or a media query, \u003Ccode>useSyncExternalStore\u003C\u002Fcode> is the right seam, not an effect that copies it into state.\u003C\u002Fp>\n\n\u003Ch2>If your project is not on 19.2 yet\u003C\u002Fh2>\n\n\u003Cp>The hook landed in React 19.2, so 18 and early 19 releases do not have it. The npm package \u003Ccode>use-effect-event\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Our fix was four lines: move the filter into one \u003Ccode>useEffectEvent\u003C\u002Fcode>, delete two refs, and remove a lint suppression comment from 2024. The duplicate orders stopped the same day. The before-and-after lives in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> now, because I have explained this to three teammates in two weeks and pointing at working code beats pointing at a docs page.\u003C\u002Fp>\n\n\u003Cp>Same app, other half of the story: effects firing twice in development, and the fix nobody reaches for. \u003Ca href=\"\u002Fposts\u002Freact-useeffect-runs-twice-strict-mode\u002F\">What StrictMode is actually telling you\u003C\u002Fa>.\u003C\u002Fp>\n","2026-11-07",1794383305991]