Robin Malfait

June 2, 2024

Conditional React hooks pattern

Recently I was refactoring some internal hooks in Headless UI and there is a pattern we use often to enable React hooks conditionally and thought I would write about it.

Often you want to use a React hook based on certain conditions, but that's not allowed by the rules of hooks. But luckily for us, there is a relatively simple trick you can use to conditionally enable or disable hooks instead.

Let's take a look at the problem first. In Headless UI we often have components that can be "open" or "closed". For example a <Menu /> component or a <Dialog /> component. Once they are open, we want to enable some functionality but when they are closed we want to disable that functionality again.

Two hooks we use are the useOutsideClick hook and the useScrollLock hook.

  • useOutsideClick this hook calls a callback when you click outside of a given element. This is used to close the <Dialog /> for example.
  • useScrollLock this hook prevents you from scrolling the page when the component is open. This is useful in <Dialog /> components so that you can't accidentally scroll the page behind the open <Dialog /> component, which would be a suboptimal user experience.

Starting with the useOutsideClick hook, a very simple and naive implementation looks like this:

function useOutsideClick(elementRef: React.MutableRefObject<HTMLElement | null>, cb: () => void) {
  useEffect(() => {
    let element = elementRef.current
    if (!element) return

    function handle(e: MouseEvent) {
      if (!element.contains(e.target)) {
        cb()
      }
    }

    document.addEventListener('click', handle)

    return () => {
      document.removeEventListener('click', handle)
    }
  }, [elementRef, cb])
}

This hook can now be used in the <Dialog /> component like this:

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(elementRef, () => { 
    onClose() 
  }) 

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

One issue with this approach is that the callback passed to the useOutsideClick hook will be called on every outside click, even when the <Dialog /> is closed.

It's not the end of the world because we would try to close an already closed <Dialog />, but it could result in unnecessary re-renders. There is another catch, but we'll get to that later.

We can easily prevent calling the onClose function by checking whether the <Dialog /> is open or not.

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(elementRef, () => {
    if (!isOpen) return

    onClose()
  })

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

Now we won't call the onClose function when the <Dialog /> is closed. But the catch I mentioned earlier is that the useOutsideClick hook will still be active when the <Dialog /> is closed. Sure, we will end up with a no-op, but we are potentially wasting memory and doing more work than necessary because we are still setting up that event listener.

If we briefly take a look at the useScrollLock hook, we have a bigger problem. A very simple implementation looks like this:

function useScrollLock() {
  useEffect(() => {
    let previous = document.documentElement.style.overflow

    // Adding `overflow: hidden;` to the `<html>` element will prevent the page
    // from scrolling. At least, in desktop browsers.
    document.documentElement.style.overflow = 'hidden'

    return () => {
      document.documentElement.style.overflow = previous
    }
  }, [])
}

It doesn't really have a callback, so there is no obvious spot where we can check whether the <Dialog /> is open or not. The page is locked the moment you use the useScrollLock hook. Uh-oh.

The pattern

The small pattern we use is fairly simple and straight forward, let's take a look. You're not ready for this.

... what if hooks take an enabled value as the first argument. Crazy, I know.

function useOutsideClick(
  enabled: boolean, 
  elementRef: React.MutableRefObject<HTMLElement | null>,
  cb: () => void,
) {
  useEffect(() => {
    if (!enabled) return

    let element = elementRef.current
    if (!element) return

    function handle(e: MouseEvent) {
      if (!element.contains(e.target)) {
        cb()
      }
    }

    document.addEventListener('click', handle)

    return () => {
      document.removeEventListener('click', handle)
    }
  }, [enabled, elementRef, cb]) 
}
function useScrollLock(enabled: boolean) { 
  useEffect(() => {
    if (!enabled) return

    let previous = document.documentElement.style.overflow

    document.documentElement.style.overflow = 'hidden'

    return () => {
      document.documentElement.style.overflow = previous
    }
  }, [enabled]) 
}

Tadaa! 🎉

It's a very simple pattern, but it's also very powerful. In case of the useOutsideClick hook, we don't have to check inside the callback whether the <Dialog /> is open or closed anymore.

But wait, isn't this the same as before? We still have to check the enabled value, right?

While that's true, the check now happens in the useEffect hook as the very first thing. This means that we don't even setup the event listener, and in case of the useScrollLock hook, we don't lock the page if we don't need to.

This is how we use it in the <Dialog /> component:

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(isOpen, elementRef, () => { 
    onClose() 
  }) 
  useScrollLock(isOpen) 

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

You might be wondering why we put the enabled boolean as the first argument instead of the last argument. If it's the last argument, then you can even use a default value.

While that is all true it's nothing more than a stylistic choice because in Headless UI we noticed two things that influenced this decision:

  1. We always passed in a variable or a condition for the enabled value, so we would never use the default value.
  2. Prettier formats the code in a different way when the enabled boolean is the first argument, which I personally preferred.

Here is a comparison:

useOutsideClick(enabled, elementRef, () => {
  onClose()
})

vs

useOutsideClick(
  elementRef,
  () => {
    onClose()
  },
  enabled,
)

or

useOutsideClick(
  () => {
    onClose()
  },
  elementRef,
  enabled,
)

Conclusion

In conclusion, this pattern is probably well known, but I've seen enough people ask about how you can call hooks conditionally. While you still can't call hooks conditionally, this pattern will allow you to enable or disable the hook based on a condition which is typically enough for a lot of hooks.

What do you think? Do you use a different pattern? Let me know on Twitter!

👨‍💻

Copyright © 2024 Robin Malfait. All rights reserved.