16
Optimizing callbacks inside reusable React hooks
You've created a custom react hook, useEventListener:
const useEventListener = (type, callback) => {
React.useEffect(() => {
window.addEventListener(type, callback)
return () => {
window.removeEventListener(type, callback)
}
}, [])
}
Then you realize that you have missed the type
and callback
dependency, so you add them.
const useEventListener = (type, callback) => {
React.useEffect(() => {
window.addEventListener(type, callback)
return () => {
window.removeEventListener(type, callback)
}
}, [type, callback])
}
Then you wonder to yourself, how often will this useEffect get run?
So you add a couple of console.logs detailing subscribe and unsubscribes.
const useEventListener = (type, callback) => {
React.useEffect(() => {
console.log("subscribe")
window.addEventListener(type, callback)
return () => {
console.log("unsubscribe")
window.removeEventListener(type, callback)
}
}, [type, callback])
}
You also implement this hook in another file.
function Simple() {
useEventListener("resize", () => {
console.log("hello")
})
return <div>hello</div>
}
This useEventListener will call your callback which log "hello" every time the browser resizes.
Also, subscribe will only get called one time.
Sounds great, right? Well not so fast...
If you start adding things other than a console.log inside of your callback, then callback's memory address will start changing, and React will start running your useEffect
in useEventListener
a lot more than you expected it to.
Let's add a resize count to the resize event listener
function ExternalExample() {
const [count, setCount] = React.useState(0)
useEventListener("resize", () => {
setCount((prev) => prev + 1)
})
return (
<div>
<p>Count: {count}</p>
</div>
)
}
So what do we do to solve this?
- Wrap callback in a useCallback inside of our component
- Remove callback from the useEffect
- Wrap our callback in a ref
Option 1 is feasible for this use case, but as our code base grows, it's pretty annoying making all of your peers wrap their callbacks in useCallbacks, keep in mind, this callback approach needs to apply to all reusable hooks in our application.
Option 2 is not acceptable because the useEffect could be referencing old versions of callback when it's actually getting invoked. For this use case it's fine, but for other reusable hooks, it could have a stale callback.
Option 3 is our best bet!
Let's update useEventListener to store callback inside of a ref.
const useEventListener = (type, callback) => {
const callbackRef = React.useRef(null)
React.useEffect(() => {
console.log("assigning callback to refCallback")
callbackRef.current = callback
}, [callback])
React.useEffect(() => {
console.log("subscribe")
window.addEventListener(type, refCallback.current)
return () => {
console.log("unsubscribe")
window.removeEventListener(type, refCallback.current)
}
}, [type])
}
callback
is still getting updated on every count update, but only the useEffect
that's assigning callback
is running. This is avoiding the event listener from subscribing and unsubscribing! We also don't have to add refCallback.current
in the dependency array since updating refs do not trigger rerenders, which will not trigger a useEffect
execution.
If you are happy with this approach as a reusable way to avoid adding callbacks inside of your useEffect
dependency array, then feel free to stop here.
In our code base, we have lots callbacks that get passed into reusable hooks.
Our useApi hook which interacts with external apis, accepts several callbacks: onSuccess, onError, api, and validate.
It gets pretty annoying writing this code:
const onSuccessRef = React.useRef(null)
const onErrorRef = React.useRef(null)
const apiRef = React.useRef(null)
const validateRef = React.useRef(null)
React.useEffect(() => {
onSuccessRef.current = onSuccess
}, [onSuccess])
React.useEffect(() => {
onErrorRef.current = onError
}, [onError])
React.useEffect(() => {
apiRef.current = api
}, [api])
React.useEffect(() => {
validateRef.current = validate
}, [validate])
So with that... I'd like to introduce: useCallbackRef
Which turns this verbose code above into:
const onSuccessRef = useCallbackRef(onSuccess)
const onErrorRef = useCallbackRef(onError)
const apiRef = useCallbackRef(api)
const validateRef = useCallbackRef(validate)
useCallbackRef
is written as follows:
const useCallbackRef = (callback) => {
const callbackRef = React.useRef(null)
React.useEffect(() => {
callbackRef.current = callback
}, [callback])
return callbackRef
}
But the problem with this approach is eslint will complain about callbackRef
, it doesn't know that it's a ref!
To solve this, we need to patch eslint-plugin-react-hooks to let eslint know that our useCallbackRef returns stable values.
We need to install patch-package and postinstall-postinstall
yarn add -D patch-package postinstall-postinstall
Once we have that installed, open up node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js
Go to line 907 where it has:
if (name === 'useRef' && id.type === 'Identifier') {
And update that to be
if ((name === 'useRef' || 'useCallbackRef') && id.type === 'Identifier') {
Once that is updated, run patch-package:
node_modules/.bin/patch-package eslint-plugin-react-hooks
After that runs, you should have a patch file created in a patches folder, which contains the patch that will run on postinstall.
Add the following script in package.json:
"postinstall": "patch-package"
And now the warning in the dependency array is gone.
Long term it would be great if eslint-plugin-react-hooks was updated to support this functionality, but for now it doesn't, so that's why we're patching it. There is an open PR to add this functionality: https://github.com/facebook/react/pull/20513
You still have this warning from eslint:
ESLint: The ref value 'callbackRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'callbackRef.current' to a variable inside the effect, and use that variable in the cleanup function.(react-hooks/exhaustive-deps)
But that can be solved by assigning callbackRef.current
to another variable such as callback
. You only have to do this when you're setting up subscriptions and unsubscribing from them in useEffects.
This is part one of this blog post, in the next part, I'll write about a custom eslint rule that marks the callback
passed into useCallbackRef
as "dirty", and it complains if you try invoke it.
16