DEV Community

Matt Crowder
Matt Crowder

Posted on

Optimizing callbacks inside reusable React hooks

Problem

You've created a custom react hook, useEventListener:

const useEventListener = (type, callback) => {
  React.useEffect(() => {
    window.addEventListener(type, callback)
    return () => {
      window.removeEventListener(type, callback)
    }
  }, [])
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

You also implement this hook in another file.

function Simple() {
  useEventListener("resize", () => {
    console.log("hello")
  })
  return <div>hello</div>
}
Enter fullscreen mode Exit fullscreen mode

This useEventListener will call your callback which log "hello" every time the browser resizes.

Also, subscribe will only get called one time.

See it in action here

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

See it in action here

Solution

So what do we do to solve this?

  1. Wrap callback in a useCallback inside of our component
  2. Remove callback from the useEffect
  3. 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])
}
Enter fullscreen mode Exit fullscreen mode

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.

See it in action here

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.

Going the extra mile

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])
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

useCallbackRef is written as follows:

const useCallbackRef = (callback) => {
  const callbackRef = React.useRef(null)

  React.useEffect(() => {
    callbackRef.current = callback
  }, [callback])
  return callbackRef
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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') {
Enter fullscreen mode Exit fullscreen mode

And update that to be

if ((name === 'useRef' || 'useCallbackRef') && id.type === 'Identifier') {
Enter fullscreen mode Exit fullscreen mode

Once that is updated, run patch-package:

node_modules/.bin/patch-package eslint-plugin-react-hooks
Enter fullscreen mode Exit fullscreen mode

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.

See it in action here

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.

Discussion (1)

Collapse
michaeljav profile image
michael javier mota • Edited on

Hi, could you help me with an issue in react.js? I'm junior