Creating a Keyboard Shortcut Hook in React (Deep Dive)
Recently I needed to add some keyboard shortcuts to an app I was working on. I wrote up some example code and decided to write this article about it. It goes into various types of shortcuts, caching, some potential bugs and pitfalls you might encounter, and more. 👇
Goals
I made a custom React hook to handle keyboard shortcuts. Here are the links to the demo and source code:
It can handle the following combinations of key presses:
- A single keypress: X
- A combination keypress with modifiers: ⌥ + X (Option + X)
- A combination keypress with multiple modifiers: ⌘ + ⇧ + X (Command + Shift + X)
- A sequence of characters: ↑ ↑ ↓ ↓ ← → ← → B A
It also makes sure:
- The shortcut will cache the event handler function if nothing changed (with
useCallback
) - The shortcut always uses the latest function value, not holding on to stale state (with
useLayoutEffect
) - Shortcuts won't run while you're typing in a text
input
,textarea
, orcontenteditable
element (unless you want them to)
You'll be able to run the hook in a component like this:
useShortcut('Command+Shift+X', () => console.log('¡Hola, mundo!'))
I'll make a little example to show everything working in a minimalist fashion, or you can just go to the sandbox or demo and play around with it.
Try any of those key combinations, including but not limited to the Konami Code!
Note: The Konami Code is an old cheat code for video games that has also been included as a secret in a lot of websites.
Setting Up
I don't usually like using a contrived example like count, setCount
and just increasing state, but in this case we just care about making sure the shortcut is working and accessing state properly, so I decided to make it simple. Here's a simple React app with a button that increments and displays the count, and an input.
import { useState } from 'react'
export default function App() {
const [count, setCount] = useState(0)
return (
<div>
<button type="button" onClick={() => setCount((prev) => prev + 1)}>
{`Count: ${count}`}
</button>
<button type="button" onClick={() => setCount(0)}>
Clear
</button>
<input type="text" placeholder="Type a shortcut key" />
</div>
)
}
The front end buttons aren't overly necessary, they just help with debugging. The input
will come in handy later.
useShortcut
Hook
We're going to want to be able to implement the useShortcut
hook like this:
import { useState } from 'react'
import { useShortcut } from './useShortcut'
export default function App() {
const [count, setCount] = useState(0)
useShortcut('a', () => { setCount((prev) => prev + 1) })
return <div>{/* ... */}</div>
}
So let's make the simplest version of the hook. You'll make a useShortcut
function that takes two parameters:
shortcut
- the string representing a key, key combination, or key sequence (currently just a key)callback
- the function that should be called when the correct key is pressed
It consists of a handleKeyDown
function that checks if the correct key is being pressed by comparing shortcut
to event.key
, and runs the passed in function if so. A useEffect
adds the event listener on the keydown
event, and removes it if dismounted. There's no dependency array in the useEffect
, so it will always fire.
import { useEffect } from 'react'
export const useShortcut = (shortcut, callback) => {
const handleKeyDown = (event) => {
// Single key shortcuts (e.g. pressing a)
if (shortcut === event.key) {
return callback(event)
}
}
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
})
}
Now if you press A, the count will increment.
Performance Optimization
This will work every time, because the useEffect
has no dependency array, and fires on every render. This is pretty inefficient, though. If I go to the Performance Monitor in Chrome DevTools (Command + Shfit + P in DevTools, and search for "Performance Monitor") I can see 20 or more event listeners being added with every time the shortcut is run.
In an attempt to reduce how often this is firing, I can use useCallback
on the handleKeyDown
function and pass it into the useEffect
array.
Note: Going forward, React shouldn't require so much finagling with
useCallback
anduseMemo
, but since you won't always have the option to use the latest and greatest, it's good to know how to work with this.
import { useCallback, useEffect } from 'react'
export const useShortcut = (shortcut, callback) => {
const handleKeyDown = useCallback((event) => { // Single key shortcuts (e.g. pressing a)
if (shortcut === event.key) {
return callback(event)
}
}, [])
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])}
I've wrapped handleKeyDown
in useCallback
in an attempt to memoize the function, and passed that memoized function into the dependency array of the useEffect
. I can see in the Performance Monitor that this only adds one event listener per keydown
event now instead of 30.
Holding Onto Stale State
This seems good at first - the shortcut is still working, and the Performance Monitor is looking better. However, this works because the setCount
function is using a callback as the value ( prev + 1)
), instead of updating the value directly (setCount(count + 1)
) ensuring the previous count
value is always up to date.
In the real world, we can't always guarantee that every bit of state we're working with is a setState
callback. If I make a shortcut that accesses the direct state value of count
and attempts to modify it, like so:
useShortcut('w', () => {
setCount(count + count)
})
The W shortcut will not be guaranteed to be accessing the latest count
state. You can see what happens in the below example if you press A a few times to increase the count, then press W to add count + count
If you increase the count to something like 3
, you'd expect running the W shortcut to return 6
. However, what you'll end up with is 0
.
That's because the initial state of count
gets passed into the function, and there's nothing in the useCallback
dependency array, so it never updates with the new value.
Accessing Current State
One way to solve this is to ensure that the function passed in is always wrapped in a useCallback
.
const handleW = useCallback(() => setCount(count + count), [count])
useShortcut('w', handleW)
Then add callback
to the dependency array of handleKeyDown
in useShotcut
. However, I'm not a fan of that approach because it requires that the user to always remember to memoize their function and pass in any state that might update. It should be easier to less prone to potential bugs to use a hook.
I discovered this interesting pattern to create a ref to hold the callback, and use useLayoutEffect
to to update the ref.
I've used useLayoutEffect
in the past for scrolling purposes, (for example, to make sure a reload of a page always starts at the top) but this is the first time I've seen it use in this way with refs.
import { useRef, useLayoutEffect, useCallback, useEffect } from 'react'
export const useShortcut = (shortcut, callback) => {
const callbackRef = useRef(callback) useLayoutEffect(() => { callbackRef.current = callback })
const handleKeyDown = useCallback((event) => {
if (shortcut === event.key) {
return callbackRef.current(event) }
}, [])
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
}
Using that code, I can see that it uses the correct and most up to date count
state without forcing the consumer of the useShortcut
hook to pass the callback in with useEffect
. I can also see in the Performance Monitor that it's not adding tons of event listeners the way the original code was. I'd be interested to hear more opinions on this pattern, because it does seem to add a lot of boilerplate, but it does seem to make the hook more optimized and easier to use.
Now that everything is working properly for a single keypress, we can move on to adding a combination shortcuts.
Combination Keyboard Shortcuts with Modifiers
So far, using if (shortcut === event.key)
, we can make sure the key being pressed matches the shortcut and run it accordingly. But keyboard shortcuts are usually combinations, using the modifiers at the bottom left and right of the keyboard. There are four, and they each have an event
property associated with them being pressed:
- ^ Control -
event.ctrlKey
- ⌥ Option or Alt -
event.altKey
- ⌘ Command or Windows -
event.metaKey
- ⇧ Shift -
event.shiftKey
If you're pressing Shift, event.shiftKey
will return true
.
We want to enable the use of one or multiple modifiers with a key, like so:
useShortcut('Control+C', () => {
setCount(count + count)
})
useShortcut('Command+Shift+X', () => {
setCount(count + count)
})
I decided to solve this by making an Object there the string of the modifier is mapped to the modifier event. If your shortcut has a +
, and any of those modifiers are pressed along with the key, run the shortcut.
import { useRef, useLayoutEffect, useCallback, useEffect } from 'react'
export const useShortcut = (shortcut, callback) => {
const callbackRef = useRef(callback)
useLayoutEffect(() => {
callbackRef.current = callback
})
const handleKeyDown = useCallback((event) => {
const modifierMap = { Control: event.ctrlKey, Alt: event.altKey, Command: event.metaKey, Shift: event.shiftKey, }
if (shortcut.includes('+')) { const keyArray = shortcut.split('+') // If the first key is a modifier, handle combinations if (Object.keys(modifierMap).includes(keyArray[0])) { const finalKey = keyArray.pop() // Run handler if the modifier(s) + key have both been pressed if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) { return callbackRef.current(event) } } }
if (shortcut === event.key) {
return callbackRef.current(event)
}
}, [])
// ...
}
Now pressing one or more modifiers along with the key will run the shortcut.
Note: There are some issues with the Alt key, which I discuss in the conclusion.
Shortcuts and Text Inputs
Something that will come up while creating shortcuts in the real world is deciding when and where you want them to be available. If you have a shortcut that saves the current page you're working on, maybe by pressing S, you don't want that shortcut to run while the user is typing into a text box.
I've added an options
property to the useShortcut
hook, with a default setting of disableTextInputs: true
. If the shortcut you're creating is explicitly for use while typing, you can disable it.
I've disabled it for HTMLTextAreaElement
, HTMLInputElement
where type
= text
, and contenteditable
elements.
export const useShortcut = (
shortcut,
callback,
options = { disableTextInputs: true }) => {
const callbackRef = useRef(callback)
useLayoutEffect(() => {
callbackRef.current = callback
})
const handleKeyDown = useCallback((event) => {
const isTextInput = event.target instanceof HTMLTextAreaElement || (event.target instanceof HTMLInputElement && (!event.target.type || event.target.type === 'text')) || event.target.isContentEditable
// Don't enable shortcuts in inputs unless explicitly declared if (options.disableTextInputs && isTextInput) { return event.stopPropagation() }
// ...
}, [])
// ...
}
There are other ways to handle this, like checking if tagName
is "INPUT"
, but I prefer ensuring it's a text-type input, because you might have a shortcut that works with other types of inputs, so I think this is a good solution.
Key Sequences
The last thing I want to handle is a sequence of characters, such as A + B + C all pressed in succession.
For my example, I used the Konami Code, which is "up up down down left right left right b a" pressed in succession.
const handleKonamiCode = () => {
/* ... */
}
useShortcut(
`ArrowUp+ArrowUp+
ArrowDown+ArrowDown+
ArrowLeft+ArrowRight+
ArrowLeft+ArrowRight+
b+a`,
handleKonamiCode
)
In order to set this up, I'm going to create some state to hold onto any matching combination of keys, called keyCombo
. After splitting the shortcut string by +
and putting it into an array, you can just keep adding each matching key to the keyCombo
array. If it's the last one in the sequence, run the callback. If it doesn't match the sequence, clear the queue.
import {
useCallback,
useRef,
useLayoutEffect,
useState, useEffect,
} from 'react'
export const useShortcut = (
shortcut,
callback,
options = { disableTextInputs: true }
) => {
const callbackRef = useRef(callback)
const [keyCombo, setKeyCombo] = useState([])
// ...
const handleKeyDown = useCallback(
(event) => {
// ...
// Handle combined modifier key shortcuts (e.g. pressing Control + D)
if (shortcut.includes('+')) {
const keyArray = shortcut.split('+')
// If the first key is a modifier, handle combinations
if (Object.keys(modifierMap).includes(keyArray[0])) {
const finalKey = keyArray.pop()
// Run handler if the modifier(s) + key have both been pressed
if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) {
return callbackRef.current(event)
}
} else {
// If the shortcut doesn't begin with a modifier, it's a sequence if (keyArray[keyCombo.length] === event.key) { // Handle final key in the sequence if ( keyArray[keyArray.length - 1] === event.key && keyCombo.length === keyArray.length - 1 ) { // Run handler if the sequence is complete, then reset it callbackRef.current(event) return setKeyCombo([]) } // Add to the sequence return setKeyCombo((prevCombo) => [...prevCombo, event.key]) } if (keyCombo.length > 0) { // Reset key combo if it doesn't match the sequence return setKeyCombo([]) } }
}
// ...
},
[keyCombo.length] )
// ...
}, [handleKeyDown])
}
I also added the keyCombo
length to the dependency array of handleKeyPress
since the function depends on it. Pressing a combination of keys will run the shortcut now.
Conclusion
Here is our completed useShortcut
hook: (I also added a line to ignore if event.repeat
is true, meaning a key is just being held down)
import { useCallback, useRef, useLayoutEffect, useState, useEffect } from 'react'
export const useShortcut = (shortcut, callback, options = { disableTextInputs: true }) => {
const callbackRef = useRef(callback)
const [keyCombo, setKeyCombo] = useState([])
useLayoutEffect(() => {
callbackRef.current = callback
})
const handleKeyDown = useCallback(
(event) => {
const isTextInput =
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLInputElement &&
(!event.target.type || event.target.type === 'text')) ||
event.target.isContentEditable
const modifierMap = {
Control: event.ctrlKey,
Alt: event.altKey,
Command: event.metaKey,
Shift: event.shiftKey,
}
// Cancel shortcut if key is being held down
if (event.repeat) {
return null
}
// Don't enable shortcuts in inputs unless explicitly declared
if (options.disableTextInputs && isTextInput) {
return event.stopPropagation()
}
// Handle combined modifier key shortcuts (e.g. pressing Control + D)
if (shortcut.includes('+')) {
const keyArray = shortcut.split('+')
// If the first key is a modifier, handle combinations
if (Object.keys(modifierMap).includes(keyArray[0])) {
const finalKey = keyArray.pop()
// Run handler if the modifier(s) + key have both been pressed
if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) {
return callbackRef.current(event)
}
} else {
// If the shortcut doesn't begin with a modifier, it's a sequence
if (keyArray[keyCombo.length] === event.key) {
// Handle final key in the sequence
if (
keyArray[keyArray.length - 1] === event.key &&
keyCombo.length === keyArray.length - 1
) {
// Run handler if the sequence is complete, then reset it
callbackRef.current(event)
return setKeyCombo([])
}
// Add to the sequence
return setKeyCombo((prevCombo) => [...prevCombo, event.key])
}
if (keyCombo.length > 0) {
// Reset key combo if it doesn't match the sequence
return setKeyCombo([])
}
}
}
// Single key shortcuts (e.g. pressing D)
if (shortcut === event.key) {
return callbackRef.current(event)
}
},
[keyCombo.length]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
}
Once again, you can play around with the demo or the sandbox. There is still more that can be done here - for example, handling an Alt key can be tricky becuause pressing Alt + C will actually produce "ç" (not "C") as both the output and event.key
value. Some overlapping shortcuts on the same page might have issues.
Overall, this should give you a good idea of how to work with a custom hook, avoid bugs (like holding onto stale state), improve caching (with useCallback
) and set up various types of keyboard events.
Thanks for reading! I'd be happy to hear any additional thoughts about shortcuts and hooks you might have.
Comments