Photo by Juan Gomez on Unsplash

Simple hotkeys manager in a few lines of code on RxJS & TS

Andrey Firsov
curious.andrew
5 min readNov 26, 2020

--

Hello folks! In this article we’ll explore the coolness of RxJS in handling user events by building a simple yet totally working hotkeys manager — a function like “if this shortcut is pressed do that stuff”. You have to possess at least a basic understanding of a concept of streams, pipable operators and how RxJS works, to follow through comfortably. I also will use a TypeScript here, but it doesn’t require any specific knowledge to read snippets on TypeScript, so if you are not familiar with TypeScript — probably it would not be an issue. Oh, and by the way, our manager will fit in about 10 lines of code. Awesome, right? So let’s get down to business.

As an input our hotkeys manager will consume an array of shortcuts that are presented with objects with two fields — keys to be pressed and cb to be called:

We want our manager to map an array of shortcuts into an array of observables, that emit when a needed shortcut is pressed, and then to subscribe to each of them with a provided callback as a subscriber. So our manager would look like this:

The trickiest part is to implement the factory shortcutIsPressed$FromKeys. Say we are dealing with the shortcut ‘Shift’ + ‘r’ + ‘x’. Whenever any key of the shortcut combination is pressed, be that ‘shift’, ‘r’ or ’x’, we want to know somehow if other keys of the shortcut are pressed at that moment, and if so — to emit. We can achieve that by creating an observable which emits states of all keys in a shortcut (which are pressed, and which are not) whenever any key changes its state. To get those conditions when all the keys are pressed, we can simply apply a filter operator to this observable.

To encode the state of a key we can use true/false: true if key is pressed, false — otherwise. So our desirable observable will emit arrays of booleans every time ‘shift’, ‘r’ or ‘x’ is pressed or unpressed:

Let’s begin with a little bit easier task and implement an observable that emits a state for a single key, every time its state changes. To accomplish this subtask we’ll need to have streams of all keydown and keyup events:

Now, we’ll create factories of observables that emits when a specific key is pressed and unpressed. To acomplish this, we just have to filter those events, in which key property equals to a specified key:

You can see that these two observables have a lot in common, so we can improve this code a bit and create a ‘factory of factories’ of the specific key event observables:

Now we just have to map keydown$ into true, keyup$ — into false, and to merge these streams into one:

So far so good! We’ve created a single key state observable. But how to combine states of every shortcut key together in an observable that would emit every time any key changes its state? Thanks RxJS has a combineLatest static method that does exactly what we need (don’t confuse with combineLatest operator, it has slightly different use-case):

Alright! Let’s see how this stuff works: we’ll supply it with an array of keys, e.g. [‘Shift’, ‘r’, ‘x’], and see it in action:

Watching a console while pressing the keys, one can make following observations:

  1. Observable returned by keysState$ForKeys will not emit anything before every supplied key is pressed at least once.
  2. Since all the keys pressed, it emits an array of booleans encoding keys state, whenever any key changes its state.
  3. If you keep holding any key from the shortcut or any combination of such keys, it will emit very frequently, even though the state has not changed.

Everything, except maybe p.3 which we’ll fix later, is exactly what we need.

To finish hotkeys we simply have to loop over provided shortcuts array, map each shortcut into an observable of keys state, filter those states when all keys are pressed (in our case it means arrays where every element is true), and subscribe to it with provided callback:

By that moment, we already have a working hotkeys manager, but there are some improvements we can do. First, currently keysState$ForKeys has a serious flaw: it creates two new event listeners for every key in every shortcut — one for keyup event, and one for keydown event. In case of our shortcuts array, it will create 6(!) event listeners for 2 shortcuts, where in fact, only two listeners are needed. This is because our keyup$ and keydown$ observables are cold by default. To fix it, we have to convert them into hot observables. It’s easily done via piping them with share() operator:

Now only two event listeners would be created, regardless of how many shortcuts we pass to hotkeys.

Let’s fix the wrong behaviour with the holding key case. In our current implementation, the manager will fire callbacks like a machine gun. Probably it’s not a desired behaviour, but it’s better to make an end-user to decide: let’s add to the manager a second optional argument — preventSeries flag:

We have to incorporate the preventSeries flag into hotkeys somehow. If preventSeries is true, two conditions should be met to fire a callback

  1. All keys in shortcut should be pressed
  2. In previous state at least one of the keys should not be pressed

We can achieve this by piping distinctUntilChanged operator before final filter operator:

Now hotkeys looks much better. Let’s see how it works in action:

Wonderful! It’s definitely not production ready and it has a lot of things to be improved, for example it would be nice to manage somehow created subscriptions, or to have possibility to subscribe to shortcuts at any desirable moment via something like static on method. But anyway, it works, and it works as it was expected!

To conclude

RxJS is a great instrument to deal with asynchronous events: we’ve created a configurable minimalistic hotkeys manager in just a few lines of elegant code with RxJS. It took us only to add 1 extra line to implement the preventSeries feature, which alters functionality significantly. It’s impossible to imagine that creating equivalent manager with an imperative approach would be such a funny and easy task, not speaking about how many lines of code it would take. And I guess implementing preventSeries feature would be tougher to do than to add one line in that case.

--

--