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, or contenteditable 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.

App.js
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:

App.js
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.

useShortcut.js
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 and useMemo, 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.

useShortcut.js
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.

useShortcut.js
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.

useShortcut.js
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.

useShortcut.js
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.

useShortcut.js
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)

useShortcut.js
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