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?

Table of Contents

YouTube Preview
View on YouTube

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!

Web app with title SWAPI People Search and search input
SWAPI People Search application

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.

Hovering over input element in chrome devtools showing in browser
Dev tools showing an input logged to the console

That means, we have access to our native DOM APIs!

So now, instead of the console.log statement, add:

inputRef.current.focus();
Focused search input
Focused search input

And if you reload the page, as soon is it loads, the input will be focused!

Follow along with the commit!

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.

Web console showing keyboard event from search autocomplete
Keyboard event logged to the web console

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.

Follow along with the commit!

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!

Web console showing up and down messages from keyboard events
Detecting up and down arrow keys

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.

Follow along with the commit!

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!

Search autocomplete with first result highlighted
Highlighted first search 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!

Search autocomplete with last result highlighted
Last result highlighted from focus state

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!

Search autocomplete using arrow keys to navigate results
Navigating up and down in the results

Follow along with the commit!

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.