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.