Non-reactive callbacks in React
Sometimes React’s reactivity can be a bit trigger happy. Imagine you’re writing a hook or component that receives a callback as an argument. Maybe the owner of that callback memoized it to prevent excess re-renders. If they didn’t and you reference this callback in an effect, the effect will re-run on every render. Aside from being a performance issue, this can cause undesired behaviors.
function useWebSocket(onOpen) {
/**
* This could close/reopen a socket connection on every render
*/
useEffect(() => {
const ws = new WebSocket("wss://example.com")
ws.onopen = onOpen
return () => {
ws.close()
}
}, [onOpen])
}
There’s an experimental useEffectEvent hook for this purpose, read more about it in the docs. It started from this (now outdated) RFC as useEvent and was later renamed to useEffectEvent.
I haven’t found an official polyfill for this, but there are some notable community attempts. Radix UI call it useCallbackRef, and BlueSky calls it useNonReactiveCallback. Sanity.io redistributes the BlueSky implementation under the use-effect-event package as a useEffectEvent “ponyfill”. I also see a similar implementation in the use-event-callback package mentioned in the useEvent RFC
The gist of these approaches are:
- Store the callback in a ref
- Update this ref in an effect
- (optional) Wrap it in a memoized function with useCallback or useMemo
The last step is a convenience so you don’t have to read from .current
every
time you access the function. One consideration is which effect to use for
syncing the callback. The Radix UI implementation uses useEffect
while BlueSky
uses useInsertionEffect
, which runs before useEffect
and useLayoutEffect
.
For my use case the timing subtleties don’t seem that critical but the old RFC
indicates that
callbacks should be updated before effects run
to prevent reading stale values, so useInsertionEffect
is the closest
approximation.
Dan Abromov’s comments indicate that these userland approximations don’t perfectly solve the effect use case, but I figure it’s the best we have for now. He recommends using useInsertionEffect if possible (i.e. you’re using React 18) or useLayoutEffect otherwise.
My preferred solution follows the official useEffectEvent
semantics but follow
Dan’s advice of syncing the ref as early as possible in an insertion effect.
I’ll also prefer memoization with useCallback since useMemo doesn’t guarantee
that cached values won’t be thrown away during the lifetime of a component (see
useMemo caveats).
import { useRef, useInsertionEffect, useCallback } from "react"
function useEffectEvent(fn) {
const ref = useRef(fn)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback((...args) => ref.current?.(...args), [])
}
TypeScript version
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useRef, useInsertionEffect, useCallback } from "react"
export function useEffectEvent<T extends ((...args: any[]) => any) | undefined>(
fn: T,
) {
const ref = useRef(fn)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback(
(...args: any) => ref.current?.(...args),
[],
) as T extends undefined ? () => void : T
}
Sometimes in a pinch I’ll just use this quick 2-liner without a custom hook:
const callbackRef = useRef(callback)
callbackRef.current = callback
Technically it’s not correct as explained in this RFC comment since concurrent features mean that when stuff is rendered by React isn’t always when it’s displayed. But then again, none of the userland approximations are fully correct.
In any case it’s handy having a hook to turn this into a 1-liner and remove the
need to read from .current
when accessing the callback.