Devtrium

How to add keyboard shortcuts to your React app

August 18, 20216 min read

Introduction

Don't you love it when you can easily navigate apps with keyboard shortcuts? It might seem hard to do, but it's actually pretty easy! Let's explore together how to add keyboard shortcuts to your apps.

We'll first show a simple way of doing it, and explain how it works.

We'll then also demonstrate how to use control keys (like Ctrl or Shift) in your shortcuts.

Finally, we'll extract that logic into a fully reusable hook. Fair warning: this last part will get quite technical with some advanced concepts! Bear with me though, this custom hook is very useful.

The code

Let's dive into the code straight away.

import { useCallback, useEffect } from 'react';

export default function App() {
  // handle what happens on key press
  const handleKeyPress = useCallback((event) => {
    console.log(`Key pressed: ${event.key}`);
  }, []);

  useEffect(() => {
    // attach the event listener
    document.addEventListener('keydown', handleKeyPress);

    // remove the event listener
    return () => {
      document.removeEventListener('keydown', handleKeyPress);
    };
  }, [handleKeyPress]);

  return (
    <div>
      <h1>Hello world</h1>
    </div>
  );
}

This simply logs the pressed key.

Explain it to me!

We first defined the function that will be called every time a key is pressed, handleKeyPress. It's wrapped in a callback because we'll be using it in a useEffect, and don't want it to trigger the useEffect on every render of the component.

Tip

If the reason why handleKeyPress needs to be wrapped with useCallback isn't clear to you, you can ping me on Twitter and I'll write an article about it!

The argument taken by handleKeyPress is a keyboard event, which is a standard object provided by the browser. This object contains a key property that tells you which key was pressed.

The next part is the useEffect. It's used to add an event listener to the document. An event listener is just a way to tell the browser "Hey, please ping me every time this event happens!".

In our case, we want to be pinged when the keydown event is fired (first argument of addEventListener) by calling handleKeyPressed (second argument of addEventListener)

Info

In this instance we are attaching the event listener to the whole document, which covers the full page. You can also attach an event listener to a specific DOM element by using a ref. It would look something like this:

yourNodeRef.current.addEventListener('keydown', handleKeyPress);

Finally, we have to be sure to remove the event listener when the component is destroyed – otherwise we'd have a memory leak on our hands! That's done through the return statement of useEffect.

removeEventListener is called with the same argument as when addEventListener was called and the event listener will be automatically removed.

And that's it! Simply replace the console.log by the action you want on key press and you'll have a fully functioning keyboard shortcut in your app.

We can go a step further though. Let's see how to handle cases where you want to handle key combinations, like holding down Shift and pressing another key.

Key combinations

It's actually pretty straightforward!

Remember the event object, the argument to our handleKeyPress function? Well, in addition to the key property that we're using, it also has properties that tell you whether certain keys were pressed down or not. There are four of those:

  1. ctrlKey (which corresponds to command on MacOS)
  2. shiftKey
  3. altKey
  4. metaKey

Let's say we only want to print to the console when Shift is pressed. Here's what our handleKeyPress function would look like.

· · ·
const handleKeyPress = useCallback((event) => {
  // check if the Shift key is pressed
  if (event.shiftKey === true) {
    console.log(`Key pressed: ${event.key}`);
  }
}, []);
· · ·
Tip

Note that in that case handleKeyPress will also fire when Shift is pressed by itself, so you might want to be careful with that! Usually you are checking for a specific key combination anyway so it's not an issue.

That's all and well, but what happens if you want to have more than one keyboard shortcut? In an app with a lot of different actions, you might want dozens of shortcuts. You wouldn't want to be copy-pasting the above code.

It's time for our trusted friend, custom hooks! The code will be a bit more advanced than what we've seen so far in this article, so I'll add resources to help you understand the finer points.

Extracting the logic into a reusable custom hook

As we did at the beginning of the article, let's see the code first, then we'll go through it.

Info

I'll be referring to a "hook user" in that section, who is the coder which will be using the custom hook. It might be yourself, but in a team it might also be someone else.

import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';

const useKeyPress = (keys, callback, node = null) => {
  // implement the callback ref pattern
  const callbackRef = useRef(callback);
  useLayoutEffect(() => {
    callbackRef.current = callback;
  });

  // handle what happens on key press
  const handleKeyPress = useCallback(
    (event) => {
      // check if one of the key is part of the ones we want
      if (keys.some((key) => event.key === key) {
        callbackRef.current(event);
      }
    },
    [keys]
  );

  useEffect(() => {
    // target is either the provided node or the document
    const targetNode = node ?? document;
    // attach the event listener
    targetNode &&
      targetNode.addEventListener("keydown", handleKeyPress);

    // remove the event listener
    return () =>
      targetNode &&
        targetNode.removeEventListener("keydown", handleKeyPress);
  }, [handleKeyPress, node]);
};

All right, it's quite a lot more than before. But you can notice some similarities. We have a handleKeyPress, as well as a useEffect that attaches an event listener to the keydown event with the handleKeyPress function as the handler.

You can also notice some differences. For example the first lines of the hook might seem weird to you. It's quite an advanced pattern called "callback ref". If you want to learn more about that pattern you can check out this article by Kent C. Dodds here.

The high-level explanation of why we are using that weird "callback ref" pattern is to not have to put the callback argument of the hook in the useEffect dependency array. We can't guarantee that the hook user wrapped this function in a useCallback so we don't want it to trigger the useEffect unnecessarily. This pattern allows us to get rid of that concern.

Info

Want a reminder on how dependency arrays work? I have an article on that!

You can also notice that the first argument of useKeyPress is called keys. It should contain a list of keys that should trigger the callback. For example ['a', 's', 'd', 'w'] if you want the shortcut to be triggered on those letters.

In handleKeyPress we go over that list and check if any of the keys match the event. If so, we call the callback.

Finally, we allow the hook user to optionally provide a node element to which we attach the event listener if provided. If not provided we attach it to the whole document, as we did at the beginning of the article.

So, to recap:

  1. The hook user provides keys, a list of keys, and a callback that will be called when keys are pressed. Optionally, the event listener can also be attached to a provided node
  2. We do some kung fu with the callback to skip having to add it to the dependency array of useEffect
  3. handleKeyPress is the function that checks if an event matches the provided keys, and if so, calls the callback
  4. useEffect sets up the event listener and makes sure to clean it up when the component unmounts as well

Now that we've done all of this work, it's a very simple hook to use in a component.

import useKeyPress from './useKeyPress';

export default function App() {
  const onKeyPress = (event) => {
    console.log(`key pressed: ${event.key}`);
  };

  useKeyPress(['a', 'b', 'c'], onKeyPress);

  return (
    <div>
      <h1>Hello world</h1>
    </div>
  );
}

In the example above, if either of a, b or c are pressed it will console.log. And you can reuse the hook across all of your components in the app!

What about key combinations?

You might have noticed that we lost something with that custom hook. We no longer have the ability to check for key combinations like Ctrl + f.

That can be added back, either in this hook or on a different hook. As a matter of fact, it can be a very good exercise for you to check your understanding of this article! Write a custom hook that does the same thing as the one above except that you can also check for key combinations.

And try not to look at the code above, except if you're really and truly stuck! 😁

Wrap up

I hope this article has helped you to understand how to bind event listeners to keyboard events in React!

If you didn't understand the latter part on the custom hook, don't worry about it. It's pretty advanced, and you can always revisit it later when you're more comfortable with React concepts.

Now go build awesome apps that you can navigate without touching your mouse! I love that kind of app! 😍

Package versions at the time of writing

Did you enjoy this article?

If so, a quick share on Twitter could really help out!