Skip to content

Instantly share code, notes, and snippets.

@gragland
Last active April 29, 2022 02:10
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save gragland/81a678775c30edfdbb224243fc0d1ec4 to your computer and use it in GitHub Desktop.
Save gragland/81a678775c30edfdbb224243fc0d1ec4 to your computer and use it in GitHub Desktop.
import { useState, useEffect, useRef } from 'react';
// Usage
function App() {
// Create a ref that we add to the element for which we want to detect outside clicks
const ref = useRef();
// State for our modal
const [isModalOpen, setModalOpen] = useState(false);
// Call hook passing in the ref and a function to call on outside click
useOnClickOutside(ref, () => setModalOpen(false));
return (
<div>
{isModalOpen ? (
<div ref={ref}>
👋 Hey, I'm a modal. Click anywhere outside of me to close.
</div>
) : (
<button onClick={() => setModalOpen(true)}>Open Modal</button>
)}
</div>
);
}
// Hook
function useOnClickOutside(ref, handler) {
useEffect(
() => {
const listener = event => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
},
// Add ref and handler to effect dependencies
// It's worth noting that because passed in handler is a new ...
// ... function on every render that will cause this effect ...
// ... callback/cleanup to run every render. It's not a big deal ...
// ... but to optimize you can wrap handler in useCallback before ...
// ... passing it into this hook.
[ref, handler]
);
}
@Andarist
Copy link

@ianobermiller that's the risk, but IMHO it's better choice to use "first handler" only rather than dispose previous effect and setup it again on each render as people most commonly will be using inline handlers with this and won't memoize them.

the implementation doesn't account for passive events, you could also link somewhere to existing libraries doing the same thing (I actually have created this one some days ago https://github.com/Andarist/use-onclickoutside

@gragland
Copy link
Author

@ianobermiller @Andarist: Good feedback! Any reason why I can't just memoize the handler within useOnClickOutside so that it doesn't matter whether they memoize?

@Andarist: I updated the post to link to your library with a mention that your's accounts for passive events (posts now have an "also check out" section at the bottom): https://usehooks.com/#useOnClickOutside

@j-f1
Copy link

j-f1 commented Jan 9, 2019

Why not return the ref from useOnClickOutside?

@gragland
Copy link
Author

gragland commented Jan 9, 2019

@j-f1 The problem with that is if you need to have multiple hooks access the same ref. You'd want to be able to pass the ref in as an argument.

@vudzero
Copy link

vudzero commented Jan 22, 2019

I had to remove the [] observer to make sure the handler can use local state from component when called.

@DimitarNestorov
Copy link

DimitarNestorov commented Feb 19, 2019

useEffect should have ref and handler as dependencies. @MatxBerg wouldn't have had an issue.

EDIT: Actually that would be the same as removing the dependencies. Maybe leave ref inside and add an optional dependencies argument for handler dependencies?

EDIT: Or we actually do put handler inside the dependencies, and leave a note to the user of the hook the they should use useCallback.

@epzilla
Copy link

epzilla commented Feb 20, 2019

@MatxBerg @DimitarNestorov I like the idea of making an optional dependencies argument, and if none is provided, stick with the empty array. I ran into a similar situation using this hook today, where I needed to access some local state inside the handler, and based upon it, decide whether I wanted to do anything further or not, and because of the empty deps array, I was getting the original bound values. So allowing the passing in of an optional deps array would alleviate that.

@gragland
Copy link
Author

gragland commented Feb 25, 2019

@ianobermiller @DimitarNestorov @epzilla @MatxBerg Thanks, I've added ref and handler to the effect deps. I think that makes sense. Running callback/cleanup on every render is not a big deal and like you say, user can always wrap handler in useCallback to optimize.

@fostyfost
Copy link

Why not click event?

@danielanthonyl
Copy link

I am using the hook on a menu button. The button toggles a dropdown and when the dropdown is visible, I use this hook as a acessibility so the user can click outside the dropdown it toggles off too. the problem is that the button itself makes the dropdown toggles twice: off and on.

@gragland
Copy link
Author

gragland commented Sep 9, 2020

@notnishi if you'd like to make the button do nothing when the menu is open you could try:

const buttonRef = useRef();
const menuRef = useRef();
useOnClickOutside(menuRef, event => {
  if (!buttonRef.current.contains(event.target)) {
    setMenuOpen(false);
  }
});

Another option would be to add menuRef to an element that wraps both the button and menu. That way clicking the button doesn't trigger the hook handler function and you can just have the button onClick event close the menu itself.

Finally, if that's not an option, you could try react-cool-onclickoutside, which supports adding a special className to elements to prevent them from triggering the hook handler.

@RoHodson
Copy link

I am getting: Uncaught TypeError: ref.current.contains is not a function

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment