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 <textarea value={draft} onChange={e => 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 <textarea value={draft} onChange={e => 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,thisincluded. -
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 = <T extends Fn>(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
fnclosing over fresh state; the effect stashes it inhandlerRef. The returned wrapper — memoized once with an empty dependency array — readshandlerRef.currentat call time, not at render time. Stable shell, fresh core.The ref updates in a layout effect, not a passive effect.
useIsomorphicLayoutEffectruns synchronously after DOM mutation, before the browser paints and before passiveuseEffectcallbacks. 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.
useLayoutEffecton the server prints a hydration warning;useIsomorphicLayoutEffectswaps inuseEffectduring 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:
-
useEffectEventis 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. -
useEventcovers 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 userlanduseEventis 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
useEffectEventinside effects on React 19.2+, anduseEventfor stable identity across component boundaries — or useuseEventeverywhere below 19.2, whereuseEffectEventdoesn'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 (
<li onClick={() => onSelect(item.id)} className="row">
{item.label}
</li>
);
});
function List({ items }: { items: Item[] }) {
const [selected, setSelected] = useState<string[]>([]);
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 (
<ul>
{items.map(item => (
<Row key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
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();
}, [handleSave]); // stable → editor created exactly once
return <div id="mount" data-dirty={dirty} />;
}
Recreating a heavyweight editor because docId changed identity in a closure is exactly the kind of waste useEvent deletes.
The Rules
Two, and they're both consequences of the ref timing:
- Don't call the returned function during render. It's for event handlers, effects, timers, callbacks — things that fire after commit. During render, the ref may still point at the previous closure.
-
Don't use it to lie to yourself about effects. If an effect genuinely should re-run when a value changes (a query refetch when filters change, say), wrapping the logic in
useEventto silence the linter buries a real dependency.useEventis for "read the latest, don't re-fire"; it's not a universal dependency-array mute button.
Takeaways
-
useEventreturns a function with permanent identity and an always-fresh closure — the two thingsuseCallbackmakes you choose between. -
The trick is a ref updated in a layout effect plus a once-memoized wrapper that reads the ref at call time.
useIsomorphicLayoutEffectkeeps it SSR-safe. -
useCallbackfor render-time values,useEventfor fired handlers. Never call auseEventfunction during render. -
On React 19.2+,
useEffectEventcovers the inside-an-effect case with linter enforcement;useEventcovers stable handlers passed across component and library boundaries. -
Check the library first —
useEventListener,useInterval,useDebounceFnand friends already ship with stale-closure protection built in.
Grab it from @reactuses/core and retire the dependency-array whack-a-mole.
Top comments (0)