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:
- We always passed in a variable or a condition for the
enabled
value, so we would never use the default value. - 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!