How to Use Browser Event Listeners in React for Search and Autocomplete
The web (mostly) revolves around interactions, where people might be trying to accomplish a task or check in on something. As developers, we need a way to hook into these interactions regardless of the tools we use. While React gives us a lot of help with this out-of-the-box, how can we break free to leverage the full APIs of browsers?
What are Event Listeners in the browser?
Most of those interactions trigger “events” where in JavaScript, we have the ability to listen for those events, and subsequently do something whenever we detect that one of those events occurred.
A super common event is listening for a click. Maybe you have a button that you want to do something special. Or maybe you’re listening for the content of an input to change. When that happens, you could be validating that input to make sure it’s well, valid.
To do this in JavaScript, we generally select the element we want to listen “on” and what event we want to listen for. While it involves much more than that, generally that listener could look like:
document.querySelector('#my-button').addEventListener('click', () => {});
document.querySelector('#my-other-button').addEventListener('mouseover', () => {});
window.addEventListener('resize', () => {});
How do Event Listeners work in React?
The issue is, while React supports a variety of events natively, sometimes it just can’t accomplish your goal, and you need to find a way to listen for the events manually.
Luckily, we have a variety of tools that can help us manage our events without completely breaking free of React.
For finding direct access to DOM nodes, we can take advantage of refs that let us use APIs native to the browser right with that element.
Or the useEffect hook, which will allow us to run some code after the component renders inside of the browser, allowing us to add our event listeners and such that might not make sense in the React lifecycle.
What are we going to build?
In this walkthrough, we’re going to learn how to create browser event listeners while working inside of a React app.
To do that, we’ll start off with a basic Next.js app that I put together just to get up and running with an example of search with autocomplete using The Star Wars API (SWAPI).
Once the project is set up, we’ll dig into how we can hook into native browser events to do things like focus on a search input on page load and listen to keyboard events to navigate a list of autocomplete suggestions.
Step 0: Starting a new React app with a Next.js demo project
Let’s get started by creating our application!
We’re going to use this demo application which includes an example of adding autocomplete to a search input for the Star Wars API.
To get that up and running, in your terminal run:
yarn create next-app -e https://github.com/colbyfayock/my-swapi-search my-search-events
# or
npm create-next-app -e https://github.com/colbyfayock/my-swapi-search my-search-events
This will go through and clone the starter project and install all of the dependencies.
Note: feel free to change
my-search-events
to the directory and project name of your choice!
Once everything is installed, navigate to that new directory:
cd my-search-events
Then, start up the new project by running:
yarn dev
# or
npm run dev
Which will start up a local development server at http://localhost:3000 where you can now access your new Next.js app!
Step 1: Automatically focusing on a search input on page load in React
For our first example, we’re going to directly interact with DOM nodes from inside of React.
To do that, we’ll use refs, which is a sort of “escape hatch” provided by React to let us connect to the elements we need to work with.
At the top of pages/index.js
update the React import statement to include useRef
:
import { useState, useRef } from 'react';
We then want to create a new ref. At the top of the component in the same index.js
file, add:
const inputRef = useRef();
Note: if you’re following along with the SWAPI example, it’s a good idea to put the ref below
hasResults
to avoid ordering issues later.
Then we’ll want to associate our ref with the element.
Scroll down to the form on the page, where inside there will be an input with the name of “query”.
On that input, we’ll add our ref:
<input ref={inputRef}
At this point, after React renders for the first time, you’ll then have access to that input’s node right from inside of React.
This is an important distinction, as you’ll notice it won’t be available if you try to access it during that first render.
But that means, in order to access it, we’ll need to treat it like an effect, where we’ll use the useEffect
hook.
To start, we’ll import useEffect
by updating our import statement again:
import { useState, useRef, useEffect } from 'react';
Next, after our inputRef
statement, add the following:
useEffect(() => {
console.log(inputRef.current);
}, []);
This will run the function inside of the useEffect
hook once after the first render of the component. It will only run once because we’re passing in an empty array, which tells React it should run, but it doesn’t have any dependencies we want to listen to changes on.
If we look inside of our browser and look at the dev tools, we should now see that we’re logging out our input element.
That means, we have access to our native DOM APIs!
So now, instead of the console.log
statement, add:
inputRef.current.focus();
And if you reload the page, as soon is it loads, the input will be focused!
Step 2: Listening for keyboard events in React
Taking this a step further, we may want to broadly listen for events, such as someone using a keyboard or resizing their browser window, where we wouldn’t have access to a ref that would make sense in that case.
Similar to Step 1, we can still take advantage of useEffect
to run things in the browser, such as adding event listeners.
Under the useEffect
from Step 1, add the following:
useEffect(() => {
document.body.addEventListener('keydown', onKeyDown);
}, []);
function onKeyDown(event) {
console.log(event);
}
In the above, we’re adding a new event listener so that whenever someone uses their keyboard, it fires that new function. We’re also logging the event so we can take a look at what that looks like.
One thing we need to consider when adding event listeners in React is also making sure we remove them when we’re finished with them.
When using the useEffect
hook, we’re adding that event listener when the component mounts, but when it unmounts, such as if you navigate to a different page, that event listener is still hanging out waiting for events.
So to clean that up, we can return a new function from our useEffect
function which removes that event listener:
useEffect(() => {
document.body.addEventListener('keydown', onKeyDown);
return () => {
document.body.removeEventListener('keydown', onKeyDown);
}
}, []);
But if you notice inside of the event being logged to the console, we can even see what key was pushed, meaning, we can listen specifically for the up and down arrows, which we’ll do in the next step.
Step 3: Firing code when triggered by specific keys
Inside of the event that we’re logging to the console, we’ll see that there’s a property called key
that will tell us exactly what we press.
In our case, we want to listen for two events:
- ArrowUp
- ArrowDown
Which both do what they sound like.
To start off, let’s first determine when one of those keys were pressed.
Inside of the onKeyDown
function, add:
function onKeyDown(event) {
const isUp = event.key === 'ArrowUp';
const isDown = event.key === 'ArrowDown';
if ( isUp ) {
console.log('Going up!')
}
if ( isDown ) {
console.log('Going down!')
}
}
We’re checking out our event’s key to see if it matches one of those values, and assigning a constant to make it easier to read.
If we now open up our browser and try to press up or down, we should now see our message!
Now one issue with this, is this can happen at any time. If we refresh the page and push up or down, it logs out that statement. We only want this to happen if we actually have search results.
To fix this, let’s head back to our useEffect
from Step 2. We’re currently passing an empty array ([]
) as the dependencies to our effect, but we can also pass in a variable, which will tell React that whenever that changes in a new render, we want to also fire the effect hook.
We also already have an existing hasResults
variable which we can use as this dependency.
Update the instance of useEffect
to the following:
useEffect(() => {
if ( hasResults ) {
document.body.addEventListener('keydown', onKeyDown);
} else {
document.body.removeEventListener('keydown', onKeyDown);
}
return () => {
document.body.removeEventListener('keydown', onKeyDown);
}
}, [hasResults]);
If you notice we have a few changes:
- We’re adding our
hasResults
variable as a dependency - Before adding our event listener, we make sure we have results
- If we don’t have results we now remove the event listener
We’re adding that additional removal of the event listener as yet another way to clean up our resources when we’re not using them. While this is a simple example, the more listeners you have, the more resources the browser you’ll use, which will impact performance.
But now, you’ll see that we will no longer see our “Going up!” and “Going down!” messages unless we specifically focused on the input and started typing a search that yields a result.
Next, we’ll learn how to use these events to actually navigate a list of results.
Step 4: Using arrow keys to navigate through a list of search results
Now that we can determine exactly what keys are pressed, we can now use that information to let our visitors navigate results.
To do that, we’re going to take advantage of the focus state in the browser. It’s the same state that you’ll see if you use the tab key to navigate around the application.
We can actually find out programmatically what element is focused. If you open up your developer tools, click into the search input, then simply run the following:
document.activeElement
You’ll see that it shows the search input!
Note: when clicking away from the input to the developer tools to run the command, it will appear as if the browser has lost focus, but that’s only because you are now focused on the console.
So to start, whenever we have results, we’re focused on the input, and someone presses down, let’s focus on the first element.
To start, let’s check to see if our input is focused. Under the isUp
and isDown
add:
const inputIsFocused = document.activeElement === inputRef.current;
We’re able to use the same inputRef
as earlier to check if our active element is in fact our input.
Before we can focus on one of our elements, we also need to have access to those elements. We’ll use a similar method to our input by adding a ref.
Under our inputRef
at the top of the component add:
const resultsRef = useRef();
Then on the unordered list (ul
) with a class of people
add:
<ul ref={resultsRef}
We can’t predict how many results we’ll have, so it’s not reasonable to try to add a ref to each one. Instead, we can add a ref to the parent that includes all of the results, which we can use along with the index to grab that result.
Back inside of onKeyDown
, we want to access these results.
Under our constants at the top of the function like inputIsFocused
add:
const resultsItems = Array.from(resultsRef.current.children)
When accessing our unordered list, we can use the children
property to get all elements nested inside that element. This will return a Node list.
Then, to have an easier way to access our elements programmatically, we’ll transform that into a standard array by wrapping it in Array.from
.
But now, inside of the isDown
if statement, add:
if ( isDown ) {
console.log('Going down!')
resultsItems[0].querySelector('a').focus();
}
We’re selecting the first item of the results, looking for the anchor tag which we need to find to add the correct focus, then using the focus
method to add our focus.
If we open up our browser, type a few characters for some results (like sky
) and hit down, we should see we highlight our first result!
Now if we try to hit down again, nothing will happen, but now we can take this a step further.
We only want to select the first result if we’re actively focused on our input, so let’s update to a new if statement:
if ( inputIsFocused ) {
resultsItems[0].querySelector('a').focus();
}
Once we hit that first item, we want to look for the next item to use to focus. To do that, we need to find its index.
Up above our if statements and below resultsItems
, add:
const activeResultIndex = resultsItems.findIndex(child => {
return child.querySelector('a') === document.activeElement;
});
Here, we’re looking through all of our results, looking for each of their anchor tags, and seeing if they are the active element. Similar to how we checked if the input was focused before!
This will give us a number value 0 or greater if it’s found and -1 if it’s not found, which will be the index of the item in the array.
Now, let’s add an if else
statement after our new if
statment:
if ( inputIsFocused ) {
resultsItems[0].querySelector('a').focus();
} else if ( resultsItems[activeResultIndex - 1] ) {
resultsItems[activeResultIndex - 1].querySelector('a').focus();
}
We’re using our index, checking if the next index exists (essentially not -1), and if it does exist, using it to update what we’re focused on.
If we head back over to the browser, find some kind of search that shows a few results (like sk
) and hit down multiple times, we can see that it now goes through the list!
But if you try to hit down after that, you’ll notice again, it does nothing. We need to make sure it loops back to our input.
Now we can add an else
statement and if none of our other conditional statements match, we’ll make sure to go right back to the input.
if ( inputIsFocused ) {
resultsItems[0].querySelector('a').focus();
} else if ( resultsItems[activeResultIndex + 1] ) {
resultsItems[activeResultIndex + 1].querySelector('a').focus();
} else {
inputRef.current.focus();
}
We’re using the same thing from Step 1 to focus back on our input.
And now, we can keep hitting down as many times as we want, and it cycles through our results back to the search input!
Finally, we’re only listening to the down arrow, we want to do the same for the up arrow.
Luckily, the logic is basically the same, so let’s update that to:
if ( isUp ) {
console.log('Going up!');
if ( inputIsFocused ) {
resultsItems[resultsItems.length - 1].querySelector('a').focus();
} else if ( resultsItems[activeResultIndex - 1] ) {
resultsItems[activeResultIndex - 1].querySelector('a').focus();
} else {
inputRef.current.focus();
}
}
There are two key differences in the above.
First, if our input is focused, we don’t want to go to the first item, we want to go to the last, so we use the length of the array and subtract one, to get that last item to focus on.
Additionally, we don’t want to find the next item in our list, we want to find the previous, so we subtract 1 from our active result index instead of adding 1.
But now, if you reload the browser, you can go both up and down with your arrow keys, cycling through all of the results!
What can we do next?
Clear results when hitting escape
You can use the same method to listen for the Escape key. A result from hitting escape is whatever interaction is active, it cancels out.
Listen for the Escape key and both clear the results and input on the event.
Other event listeners
We can use this same method to listen to other events like when resizing a browser.
Add an event listener for the resize event on the browser window to see how that works for building responsive functionality.