How to detect clicks on any element on the React page?

How to detect clicks on any element on the React page?
Photo by Nick Fewings / Unsplash

React is very flexible with event listeners. You can use:

  • clicks events when a mouse click event occurs,
  • mouse over events when the mouse pointer moves over an element.
  • scroll events when the user scrolls in an element,
  • keydown events when a keyboard key is pressed.,
  • change events when the value of an input element changes,
  • other events

That said React allows you to use all these event listeners, but most of the time you will listen to clicks on certain elements, like buttons:

<button onClick={() => console.log('Helo world!')}>Hello world</button>

This approach is very basic and it works fine for this use case. When you click on the button it will print the "Hello world!" message in the console.

Now, imagine that you have a select element (dropdown) with 10 options. Everything works fine, but you get a UX bug reported by the client. In that bug they want you to close the dropdown selection box when the user clicks outside of the dropdown.

How to fix that?

Detect clicks anywhere

You should never forget that React is a JavaScript library and everything available in native JavaScript, you can use in React as well.

That said, you can also use browser web APIs:

import { useEffect } from 'react';

useEffect(() => {
  document.body.addEventListener('click', () => {
    console.log('Hello world!');
  });
}, []);

In this example, we are attaching onClick event listener to the document body when the component mounts in the web browser.

This will work fine, but you always want to target a specific element and listen to clicks on that targeted element.

To achieve this we can add some more code in the example above:

import { useEffect } from 'react';

useEffect(() => {
  document.body.addEventListener('click', (event) => {
    console.log(event.composedPath());
  });
}, []);

This method composedPath will give you an array of HTML elements that are involved in the click event. It will start with the element that was clicked, and it will go up to the window element.

You will get something like this in the console:

In this example, I clicked on the image element, it was wrapped inside a div and it goes through all the elements up to the window.

Now, let's say you have a popover component that displays additional information when you click on a specific element. That popover can benefit from click detection outside of the popover area.

This allows users to close the popover by clicking anywhere else on the screen.

So this is the code:

import { useRef } from 'react';
const popoverRef = useRef();

<div ref={popoverRef}>
...
</div>

We attach the ref to our popover component so that later we can identify it easily.

import { useEffect } from 'react';

useEffect(() => {
  document.body.addEventListener('click', (event) => {
    const includesPopoverElement = event.composedPath().includes(popover.current);
    if (popover.current && !includesPopoverElement) {
      // Dismiss popover
    }
  });
}, []);

If our popover is there in the composed path array, it will ignore it. But if the popover is not included, we can execute some code that will hide the popover element.

This of course depends on your implementation and logic.

Cleanup

Now that we have all the parts together, we should also do the cleanup.

When you run your code locally, you won't notice any performance issues, especially if your app is smaller.

But if you have a more complex app, stacking the event listeners without clearing them when components unmount, can cause big performance issues.

Because of that, you should return a function in the useEffect and when the component is unmounting it will remove the event listener.

So for example, if you navigate to the new page, and that new page doesn't use this component, it will be unmounted.

You must remove the event listener like this:

import { useEffect } from 'react';

function onClickHandler(event) {
  const includesPopoverElement = event.composedPath().includes(popover.current);
  if (popover.current && !includesPopoverElement) {
    hidePopover();
  }
}

useEffect(() => {
  document.body.addEventListener('click', onClickHandler);
  return () => {
    document.body.removeEventListener('click', onClickHandler);
  }
}, []);

By extracting our logic into a separate function we can use that same function when attaching the click event and also when removing it. To clean the event listener you must pass the same function reference, otherwise it won't work.

Conclusion

When building web apps, users will expect these little details to work, otherwise they will get frustrated easily.

By just removing unnecessary parts of the user interface when they're done with them, we're helping users stay focused on what they're doing and clearing away anything that might get in their way.