Blog | ProbableTrain

Adding Keyboard Shortcuts to a React App

Keyboard shortcuts and combinations are a good way to level up your users - by speeding up interactions with your app you turn them into power users. Here's an overview of how to do it in React, along with some tips, tricks, and pitfalls.


I use these methods in Dungeon Scrawl and notes.probabletrain.com - both of which are React apps aiming to cater for power users. notes.probabletrain.com in particular is a project born out of frustration with how necessary a mouse is for using the Mac Notes app, and how unreasonably far away my mouse is from my keyboard.


To save over 10s of milliseconds per day switching from mouse to keyboard and back, I opted to spend several hours coding up my own solution complete with a robust set of keyboard shortcuts. This should pay off in a couple of centuries.


Let's dive into it.

Demo

This page is interactive! Try some of the keyboard shortcuts below:

  • +k
  • +shift+kg
  • *g
  • upupdowndownleftrightleftrightba

Keys separated by a '+' need to be pressed at the same time. Keys separated by a space should be pressed in sequence.

Library Choice

The main library options I've explored are Mousetrap and Hotkeys. Either would be a good choice - the Venn diagram of their feature set have a lot of overlap. I'm using Mousetrap mainly out of familiarity and syntax preference. IMO it also has a cooler name, which has to count for something.


🔗 https://craig.is/killing/mice - it's rare to look at a URL and think 'heh'

💻 https://github.com/ccampbell/mousetrap

yarn add mousetrap @types/mousetrap

Example Usage

Mousetrap.bind(["g ? command+enter", "mod+t"], (event) => {
// Do something
});
Mousetrap.unbind("g ? command+enter");
Mousetrap.reset(); // Remove all keybinds
Mousetrap.trigger("mod+t"); // Trigger callback for keybind

Registering and Deregistering in React

We could register these shortcuts globally, but then we'd have no way to interact with our React components - the callbacks wouldn't have access to any of our React state. Registering from within a React component allows us to access state, and also gives us some control over the lifecycle of the shortcuts.


Registering shortcuts is a side effect, so we should do it from within a useEffect hook. Returning a function from useEffect allows us to clean up side effects when the effect runs, or when the component unmounts. We return a function that uses Mousetrap.unbind to remove this component's keybinding, so the keybind is only alive for as long as this component tree is alive. In larger interfaces, you can use this to create keybinds specific to components, such as a keybind for a specific dialog that will only trigger when the dialog is open.

export const LightToggle: React.FC = () => {
const [lightOn, setLightOn] = useState(false);
useEffect(() => {
console.log("Binding l"); // This is called foreshadowing
Mousetrap.bind("l", () => setLightOn(!lightOn));
return () => {
console.log("Unbinding l");
Mousetrap.unbind("l");
};
}, [lightOn]);
return <b>The light is {lightOn ? "on" : "off"}.</b>;
};

Try it out! Press L to toggle the light:

The light is off.

Dependencies

There's an unfortunate issue with this. Our useEffect runs whenever the dependencies change - whenever the value of lightOn changes, we're unbinding then rebinding the shortcut. If you open your browser console (right click, dev tools), you can see this happening.


It's necessary for the effect to clean up and rerun in the code above so that the callback we give to Mousetrap has an up-to-date value for lightOn. It works but it'd be nice to avoid unnecessary unbinding and rebinding if we can, so we have a higher performance ceiling when the app gets more complex. For example, when registering and deregistering large amounts of shortcuts that depend on complex values, some of which are fast-changing. Not only will the shortcuts unbind and rebind when the shortcuts are triggered - they'll rebind whenever the state changes by any other means.


Here are some strategies:

Passing a Function to setState

We're currently giving setLightOn a boolean value: setLightOn(!lightOn). We can instead pass a function, the argument of which is the previous state value: setLightOn(previous => !previous). This allows us to remove lightOn from the useEffect dependency array completely, so the binding will only happen once.


This method is implemented on this page - if you scroll back up to the light demo and press k with the console open, you'll see that there are no console logs for unbinding/rebinding 'k'. There are logs for unbinding 'L' though, since the value in the dependency array is changing.

Shadowing State with useRef

useRef creates a mutable ref object. Changing the current value of this object doesn't trigger a React render. Importantly, we can pass the ref object to a callback, and the .current value will be up-to-date. Here's a quick code sample to demonstrate what I mean:

const lightOnRef = useRef(true);
useEffect(() => {
Mousetrap.bind("k", () => {
const lightOn = lightOnRef.current;
// lightOn will be up to date
});
// We don't need to pass any dependencies, since the mutable ref object returned
// by useRef won't change - the value of `.current` changes.
}, []);

There's a problem though - we lose the reactiveness that makes React React. React components depending on the value of lightOnRef.current won't rerender when its value changes. To get the best of both worlds, here's a simple hook that has its cake and eats it too.

function useStateRef<S>(initialValue: S) {
const [state, _setState] = useState(initialValue);
const valueRef = useRef(initialValue);
const setState = useCallback((value: S) => {
_setState(value);
valueRef.current = value;
}, []);
// `as const` gives us proper types when we destructure this array later.
// Casting to RefObject<S> means that users of this hook can't mutate the
// .current property, so the values never get out of sync.
// valueRef.current = x; // TypeError - .current is readonly
return [state, valueRef as RefObject<S>, setState] as const;
}

This hook sets React state and mutates a ref at the same time, returning both values. We can use the first value in the array when rendering so we get reactive updates as before. The second value can be used in callbacks passed to libraries like Mousetrap, so they can access the state at any time without us having to rerun useEffect hooks unnecessarily.

Using a State Management Solution

Many state management libraries such as Redux, Zustand, and MobX will provide methods of accessing state from outside React components. You can use this to access state from within keyboard shortcut callbacks without having to put anything in the useEffect dependency array.

Pitfalls

Focussed Inputs

Mousetrap, by default, won't trigger callbacks if the user is focussed on an input, textarea, or a 'contenteditable' element. This helps shortcuts being accidentally triggered if the user is typing, but adding the class mousetrap to the element overrides this behaviour. More details are in the stopCallback section of the Mousetrap docs.

Browser Shortcuts

Browsers have their own set of shortcuts. For example in Chrome, +p is used for printing the page, +d for saving a bookmark, etc. It's wise to be thoughtful about overriding these, there's nothing more annoying than an app preventing you from doing something you want to do becauser it has overriden the browser's behaviour.


If you are certain that overriding the browser's behaviour for a shortcut is the right thing to do, you can usually do so with event.preventDefault, where event is the argument to callbacks you give Mousetrap. A reasonable example of this could be overriding +s for a creation app like Dungeon Scrawl, where users already expect +s to save their progress rather than save the contents of the webpage.


There are some shortcuts that you cannot override (and for good reason), such as +w.

Onwards

That should be enough to get started! Taking this to the next level involves challenges such as:

  • Choosing good shortcuts, that feel natural and are easily memorised
  • Making sure that shortcuts work well across international keyboards
  • Moving the burden of memorising shortcuts from the user to the application. Linear and Superhuman do this very well.
  • Shortcut conflicts - what if a child component overrides the parent component's shortcut? With the current setup, when the child component unmounts, the parent shortcut will be removed too.
  • Making this key render correctly on Windows and Mac: (did you notice it flash on page load?)
  • User-defined shortcuts