selbekk

Catching clicks outside your element with useClickOutside

Catching clicks outside your element with useClickOutside

July 12, 2020
4 min read

Do you want to close your menu, slide-out or modal when people click outside of it? Let me show you how to fix that with a custom React hook.

I often create UIs that are stacked on top of other UI elements. When you have those kinds of components, you might want to close it whenever the user clicks outside of it. Sounds simple enough, right? Well, if you've tried to implement it, you know it can be a bit hacky.

At face value - React's component model lends itself extremely poorly to this kind of interaction. It's hard to encapsulate logic inside of your component if you have to check whether or not something outside of it was clicked!

Luckily, we have refs - and refs lets us solve this problem with only a few lines of code. This article is going to be short and sweet, and show you how to do that in React.

Meet contains

Back in the days, you had to do a recursive search up the DOM tree to figure out whether or not a click event was within a container. It was a world of hurt, so I'm not going to show you.

Now, we can use the DOM element's contains method to see if an element is inside another one. So if we have the following structure:

<section id="section">
  <button id="inside">Inside the section</button>
</section>
<button id="outside">Outside the section</button>

and you wanted to figure which of those buttons were clicked without attaching a click handler to each - you could use the contains method like this:

<script>
  document.addEventListener('click', clickEvent => {
    const sectionElement = document.getElementById('section');
    if (sectionElement.contains(clickEvent.target)) {
      // we clicked the button inside the section
    } else {
      // we clicked the button outside the section
    }
  });
</script>

Neat - right? But how can we use this to solve our original challenge?

useEffect && useRef === 💯

To solve this in React, we need to attach a click handler to the document element, and listen for clicks. Next, we need to attach a ref to the DOM element we want to contain our clicks in, and then we need to do something if a click is not inside of that DOM element.

Here's a language picker component we'll implement those requirements in:

const LanguagePicker = (props) => {
  const [isOpen, setOpen] = React.useState(false)
  return (
    <div>
      <button onClick={() => setOpen(prev => !prev)}>
        Toggle menu
      </button>
      {isOpen && (
        <ul>
          {props.languages.map(language => (
            <li key={language}>{language}</li>
          ))}
        </ul>
      )}
  );
};

If you've written some React, you'll see no surprises here. A button toggles whether or not to show an unordered list of languages provided by props. It doesn't close if we click outside of it though, so let's implement that.

const LanguagePicker = (props) => {
  const [isOpen, setOpen] = React.useState(false);
  
  const domRef = React.useRef();
  React.useEffect(() => {
    const handleClick = (e) => {
      if (!domRef.current.contains(e.target)) {
        setOpen(false);
      }
    };
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, []);
  
  return (
    <div>
      <button onClick={() => setOpen(prev => !prev)}>
        Toggle menu
      </button>
      {isOpen && (
        <ul ref={domRef}>
          {props.languages.map(language => (
            <li key={language}>{language}</li>
          ))}
        </ul>
      )}
  );
};

Woah - that was a lot of new code! Let's go through it step by step.

First, we create a so-called ref - short for reference - that we'll attach to the unordered list item. We use React's useRef hook to create one. Next, we use the useEffect hook to attach a click handler to the document element on mount, and remove it when we unmount.

The click handler itself does the contains check, and in our use case we'll just check if the domRef - that is, the unordered list element - contained the element that was clicked. We get that element through the click event's target attribute.

And that's basically it - if you're just doing this once, this is all the code that's needed to get things up and working.

Make it reusable!

Making stuff reusable is a fun exercise, and it often makes your code more readable, testable and maintainable in the process. So let's refactor this out to create a custom hook you can use all over your code base!

First, we decide on an API for the hook. We'll pass in a function, which is called whenever the user clicks outside the given DOM node. Next, the hook will return the ref, so that the user can attach it to the correct place in their component.

The hook looks like this:

const useClickOutside = (callback) => {
  const domRef = React.useRef();
  React.useEffect(() => {
    const handleClick = (e) => {
      if (!domRef.current.contains(e.target)) {
        callback();
      }
    };
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, []);
  
  return domRef;
}

Now, we can use this functionality like this:

const LanguagePicker = (props) => {
  const [isOpen, setOpen] = React.useState(false);
  
  const domRef = useClickOutside(() => setOpen(false));
  
  return (
    <div>
      <button onClick={() => setOpen(prev => !prev)}>
        Toggle menu
      </button>
      {isOpen && (
        <ul ref={domRef}>
          {props.languages.map(language => (
            <li key={language}>{language}</li>
          ))}
        </ul>
      )}
  );
};

Pretty neat, right?

I know there are NPM modules that do this for you, but when you notice how little code this really is, you might be tempted to just roll your own. And if you're still using an NPM package for this, you at least know how it works.

Thanks for reading this little guide :)

All rights reserved © 2024