DEV Community

React useEvent Hook: Stable Callbacks Without Stale Closures (2026)

Every React developer eventually meets the same fork in the road. You write an event handler that reads state, pass it to a child or an effect, and now you must choose: leave it as a plain inline function and watch every render create a new reference - breaking React.memo , re-running effects, re-subscribing listeners - or wrap it in useCallback and start playing dependency-array whack-a-mole, where one forgotten dependency means the handler sees state from three renders ago. That second failure mode has a name - the stale closure - and it's arguably the most common React bug in production code. The fix has a name too: useEvent , proposed in an official React RFC in 2022, and available today as useEvent in @reactuses/core . It gives you a function whose identity never changes across renders but whose body always sees the latest state and props. Both halves of the fork, no trade-off. This post covers the API, the three-line implementation trick that makes it work, how it compares to useCallback and to React 19.2's built-in useEffectEvent , real patterns, and the one rule you must respect (don't call it during render). TypeScript-first. The Problem in Thirty Seconds Here's the bug factory. A chat component sends a heartbeat with the current draft text: function Composer({ roomId }: { roomId: string }) { const [draft, setDraft] = useState(''); useEffect(() => { const id = setInterval(() => { sendHeartbeat(roomId, draft); // โš ๏ธ which draft? }, 3000); return () => clearInterval(id); }, [roomId]); // draft intentionally omitted - we don't want to reset the timer return setDraft(e.target.value)} />; } The interval closes over the draft that existed when the effect ran - the empty string. Every heartbeat sends '' forever. Add draft to the dependency array and the closure is fresh, but now the interval tears down and restarts on every keystroke. useCallback doesn't help: it has the exact same dependency array, so it forces the exact same choice - stale values or churning identity. What you actually want is a function that is one stable thing over the component's lifetime, but reads current values whenever it fires. That's useEvent : import { useEvent } from '@reactuses/core'; function Composer({ roomId }: { roomId: string }) { const [draft, setDraft] = useState(''); const beat = useEvent(() => { sendHeartbeat(roomId, draft); // โœ… always the latest draft and roomId }); useEffect(() => { const id = setInterval(beat, 3000); return () => clearInterval(id); }, [beat]); // beat never changes - effect runs once return setDraft(e.target.value)} />; } beat is referentially identical on every render, so the effect runs once and the interval survives typing. When it fires, it reads draft through the latest render's closure. The dependency array is even honest - beat is listed, it just happens to be stable. The Full API There's almost nothing to learn: const stableFn = useEvent(fn); - fn - any function. Arguments and return value pass straight through,this included. - stableFn - same TypeScript type asfn , but its identity is fixed for the lifetime of the component. The typing is exact, not (...args: any[]) => any : const format = useEvent((n: number, unit: string) => ${n}${unit}); format(3, 'px'); // โœ… string format('3', 'px'); // โŒ type error In development, passing a non-function logs useEvent expected parameter is a function, got โ€ฆ to the console instead of failing silently. How It Works Inside The entire implementation is short enough to read over coffee, and every line earns its place: export const useEvent = (fn: T) => { const handlerRef = useRef(fn); useIsomorphicLayoutEffect(() => { handlerRef.current = fn; }, [fn]); return useCallback((...args) => { const fn = handlerRef.current; return fn(...args); }, []) as T; }; Three details worth noticing: A ref carries the latest closure. Each render produces a fresh fn closing over fresh state; the effect stashes it inhandlerRef . The returned wrapper - memoized once with an empty dependency array - readshandlerRef.current at call time, not at render time. Stable shell, fresh core.The ref updates in a layout effect, not a passive effect. useIsomorphicLayoutEffect runs synchronously after DOM mutation, before the browser paints and before passiveuseEffect callbacks. If the ref were updated in a plainuseEffect , any event that fired in the gap - or any other effect running earlier in the same commit - could call the wrapper and hit the previous render's closure. The layout timing closes that window.Isomorphic means SSR-safe. useLayoutEffect on the server prints a hydration warning;useIsomorphicLayoutEffect swaps inuseEffect during SSR and the real thing in the browser. No warnings, no special-casing in your code. If this ref-holding trick sounds familiar, it's the same idea as useLatest - useEvent is essentially useLatest plus a stable callable wrapper. Reach for useLatest when you want to read a fresh value inside some existing callback; reach for useEvent when the callback itself is the thing you're passing around. useEvent vs useCallback They solve different problems, and the comparison makes both clearer: useCallback | useEvent | | |---|---|---| | Identity | Changes whenever deps change | Never changes | | Closure freshness | Only as fresh as your dep array is correct | Always latest - read at call time | | Dependency array | Required; the bug surface | None | | Callable during render? | โœ… Yes | โŒ No - event/effect time only | | Best for | Values computed during render (memoized selectors, render props) | Handlers fired later (events, timers, subscriptions) | The render-time row is the real dividing line. useCallback 's result is an ordinary value - you can call it while rendering to compute JSX. useEvent 's wrapper reads a ref that is only guaranteed current after commit, so calling it during render can observe a previous render's state (and breaks the concurrent-rendering contract the RFC was careful about). The rule of thumb writes itself: if the function fires in response to something - a click, a tick, a message - use useEvent . If it computes something during render, use useCallback . useEvent vs React's useEffectEvent The 2022 RFC was ultimately superseded: React shipped the idea as useEffectEvent , stable since React 19.2. If you're on 19.2+ you should know how the two relate: - useEffectEvent is deliberately narrower. The returned function may only be called from inside effects (the ESLint rule enforces it), and must not be passed to other components or hooks. React's team scoped it to the one pattern they considered airtight: reading fresh values from an effect without re-triggering it. - useEvent covers the wider surface. Passing a stable handler to a memoized child, an imperative widget, a WebSocket wrapper, or a third-party SDK - all thingsuseEffectEvent 's linter will reject - are precisely what a userlanduseEvent is for. The trade-off is that the wider surface includes the render-time foot-gun above, and you hold the discipline instead of the linter. - They coexist fine. Use useEffectEvent inside effects on React 19.2+, anduseEvent for stable identity across component boundaries - or useuseEvent everywhere below 19.2, whereuseEffectEvent doesn't exist. Patterns A Handler Prop That Doesn't Break React.memo The classic list-row scenario - a memoized row re-renders anyway because the parent recreates onSelect each render: const Row = React.memo(function Row({ item, onSelect }: RowProps) { return ( onSelect(item.id)} className="row"> {item.label} ); }); function List({ items }: { items: Item[] }) { const [selected, setSelected] = useState ([]); const handleSelect = useEvent((id: string) => { // reads latest selected, no dep array to maintain setSelected(selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]); }); return ( {items.map(item => ( ))} ); } handleSelect is the same reference on every render, so React.memo actually memoizes. With useCallback you'd either list selected (identity churns, memo defeated) or use the functional-update form everywhere (fine here, impossible once the handler reads two pieces of state). Subscriptions That Never Re-Subscribe WebSockets, EventSource , SDKs - anywhere tearing down a connection just because a closure went stale is embarrassing: function usePriceFeed(symbol: string, threshold: number) { const [price, setPrice] = useState(0); const onMessage = useEvent((e: MessageEvent) => { const next = JSON.parse(e.data).price as number; setPrice(next); if (next > threshold) notify(symbol, next); // latest threshold, always }); useEffect(() => { const ws = new WebSocket(wss://feed.example.com/${symbol}); ws.addEventListener('message', onMessage); return () => ws.close(); }, [symbol, onMessage]); // reconnects only when symbol changes return price; } The socket reconnects when symbol changes - a real reason - and never when threshold does. Note that for plain DOM targets, useEventListener already does this internally (it wraps your handler in useLatest ), so you only need useEvent when you own the subscription plumbing. Timers - or Just Use the Library's The heartbeat example above is common enough that @reactuses/core ships it solved: useInterval keeps your callback fresh without restarting the timer - and its own implementation is built on useEvent and useLatest . Same story for useTimeout , useDebounceFn , and useThrottleFn : the stale-closure protection is baked in, so check whether the hook you're about to build already exists before wiring useEvent yourself. Stable Callbacks for Imperative Widgets Chart libraries, map SDKs, and editors typically take handlers at construction time: function Editor({ docId }: { docId: string }) { const [dirty, setDirty] = useState(false); const handleSave = useEvent((content: string) => { saveDocument(docId, content); // latest docId setDirty(false); }); useEffect(() => { const editor = createEditor('#mount', { onSave: handleSave }); return () => editor.destroy(); }, [handle

Comments

No comments yet. Start the discussion.