Detecting clicks is a fundamental way to engage with your visitors on interaction. While it's easy to detect clicking on something, what about clicking outside of it?
Adding Event Handlers in React
Often when working with interactions, you're looking to detect a click or some kind of event directly related to a specific element.
This could be tracking clicks on a button, listening for scroll events on a container element, or when someone starts to type something into a text field.
React comes out-of-the-box with a wide variety of event listeners that allow you to easily trigger additional functionality based on interaction.
At its most basic is the onClick
handler, where applying it to an element like a <button>
, we can easily do something like trigger an alert:
<button onClick={() => alert('Hey, listen!')}>
Click Me
</button>
But adding props-based event handlers only allows us to track direct interactions with that element, where, if we were working on a search dropdown for instance and we wanted to close the dropdown when someone clicks outside of the dropdown, what element do we track and where do we attach our handler?
Or what about if you want to trigger an event handler on pretty much anything?
Detecting Clicks Anywhere on a Page in React
Moving outside of React to the browser itself, we can attach any event listener to any element we want, we just need to use native browser APIs to do so.
To do this, we can work clientside inside of a React useEffect, which allows us to run code once the component mounts inside of the browser.
To see how this works, let's first import useEffect:
import { useEffect } from 'react';
Then inside of our component, let's add a new instance of useEffect, where inside, we're going to access the page document's body and attach a basic click handler:
useEffect(() => {
document.body.addEventListener('click', () => {
console.log('Click!');
});
}, []);
A few things to note:
We're using an empty dependency array on our useEffect so it only runs on mount
We listen for the click event, but that event can be anything if its relevant to the element you're attaching it to
We're simply logging out "click" to test this out
If you open up your developer tools and click around, you should be able to see a console log!
But while sometimes its useful to track any click on a page, usually we want to target something.
Excluding an Element when Listening for an Event
Going back to an earlier example where if we have a search dropdown that shows results when someone types in a query, how can we dismiss that dropdown if someone clicks outside?
We'll start with the same event listener from the last event, where we'll listen for any click anywhere inside of the document:
useEffect(() => {
document.body.addEventListener('click', () => {
// Do something here
});
}, []);
Once that fires, we have access to an event
object, that includes the element that was clicked, as well as an important method, composedPath.
useEffect(() => {
document.body.addEventListener('click', (event: MouseEvent) => {
console.log(event.composedPath())
});
}, []);
This gives us an array of elements starting with the element that was clicked, all the way up to the window element, where we can look for a specific element in that array, to determine whether or not our element was clicked.
In our example, we might have a form with our search input, where we can look for our form in the composed path.
Starting with our form, we can access it using a ref, by first importing useRef:
import { useRef } from 'react';
Then attaching the ref to our form:
const formRef = useRef();
...
<form ref={formRef}>
This will allow us to access our form clientside with formRef.current
so inside of our useEffect, we can now check if our composed path includes our form:
useEffect(() => {
document.body.addEventListener('click', (event: MouseEvent) => {
if ( formRef.current && !event.composedPath().includes(formRef.current) ) {
// Dismiss dropdown
}
});
}, []);
If our form is included in the array, it will simply do nothing, as we don't want to interrupt someone's interaction with our search form.
But if the form is not included, we can trigger something like using a reset
function to clear the query and results!
Cleaning Up Event Listeners to Free Browser Resources
We're looking at a simple example, so we likely wouldn't notice any performance issues with that alone, but in a complex app that's using many event listeners throughout the application along with performing other intensive tasks, we want to be conscious to clean up our resources whenever we can.
Inside of our useEffect, we can return a function where when our React component goes to unmount, such as changing pages to a new page that doesn't include that component, it will remove the event listener.
useEffect(() => {
// Add event listener
return () => {
// Remove event listener
}
}, []);
The trick is, we need to reference the same function when both adding and removing our event listener, so let's first abstract our callback function:
function handleOnClick(event: MouseEvent) {
if ( formRef.current && !event.composedPath().includes(formRef.current) ) {
reset();
}
}
Then we can set up our event listener to add on mount and remove on unmount.
useEffect(() => {
document.body.addEventListener('click', handleOnClick);
return () => {
document.body.removeEventListener('click', handleOnClick);
}
}, []);
Conditionally Listening for Events
As another optimization, we don't need to always be listening for clicks, we only care about listening for clicks when our dropdown is open where in our example, its when we have an active search query.
We can use the useEffect dependency array to track the status of our results, whether it's active or not, and conditionally listen or not listen for an event.
In this example, I'm creating a constant that will either be true or false based on if there are any results:
const hasResults = results && results.length > 0;
Tip: using a non-object value is helpful in helping React safely determine if the value has changed, as objects in the dependency array are compared by reference, not value.
Inside of my useEffect, I can now add hasResults
as my dependency and if I don't have any results, I'll never run the code to add an event listener.
useEffect(() => {
if ( !hasResults ) return;
document.body.addEventListener('click', handleOnClick);
return () => {
document.body.removeEventListener('click', handleOnClick);
}
}, [hasResults]);
Tip: the useEffect return function doesn't only run on unmount. If you add a dependency, when that dependency changes, the return function will be fired as the useEffect will then execute again.
This helps me avoid wasting resources in the first place if my visitor doesn't ever interact with the search component!
Setting Expectations and Cleaning App UIs
When building applications, we want to avoid our visitors getting frustrated and try to meet exceed their expectations when dealing with interactions.
By adding something as simple as dismissing a UI they're no longer using, we're helping them to maintain focus on the task they're trying to complete and potentially removing obstacles that impede that goal.
For another way to help delight and create a great experience for your visitors, here's how to add smooth scrolling to an element in React.