How to add keyboard shortcuts to your React app
August 18, 2021 • 6 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.
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
)
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:
ctrlKey
(which corresponds tocommand
on MacOS)shiftKey
altKey
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}`);
}
}, []);
· · ·
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.
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.
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:
- The hook user provides
keys
, a list of keys, and acallback
that will be called whenkeys
are pressed. Optionally, the event listener can also be attached to a providednode
- We do some kung fu with the
callback
to skip having to add it to the dependency array ofuseEffect
handleKeyPress
is the function that checks if an event matches the providedkeys
, and if so, calls thecallback
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! 😍