Lessons about React, Keyboard Input, Forms, Event Listeners and Debugging

Alex Bostock
5 min readJan 27, 2020

tl;dr: key presses can trigger all kinds of things: form inputs, browser scrolling, and app-specific keyboard controls, to name a few. Given this, writing code to handle key presses might be more complicated than you think.

I built a tetris game. Its source is on GitHub. It’s built using React, and it should work on desktop and mobile devices. The game can be controlled using either keyboard controls or on-screen buttons, optimised for touchscreens. Making keyboard controls work was more challenging than I anticipated.

Keyboard Controls

When I first added keyboard controls, I used the most obvious mechanism: React’s onKeyDown. It didn't work. After some Googling, I found the issue: React’s keyboard event’s don’t work on an element that doesn’t have a tabIndex.

So I dutifully added a tabIndex, and the keyboard controls worked. Just the small inconvenience of needing to click to focus on the game canvas so that key presses were detected. For a while, this was the state of the app.

After some time, I found that the need for focus was less convenient in some browsers than others. So I changed the event listeners. Forget React, and just use JavaScript. Just like the code I was writing years ago, but in the middle of a React component:

document.body.addEventListener('keydown', handleKey);

All was well, mostly.

Yesterday’s Bug

Yesterday, I fixed an annoying bug. Pressing cursor keys or the space bar would simultaneously control the game and scroll the browser window.

This was not a common occurrence. On desktop, with a larger viewport, the whole app fits on screen at once, so there is no scrolling. On mobile, I’m nearly always using touch screen input, so any issue with keyboard input normally goes unnoticed.

Fixing the bug seemed easy. Just add one line to the event handler:

keyHandler = event => {
event.preventDefault();

// Respond to the input
}

An easy fix and a job well done, it seemed.

The Problem

Today, I discovered a new bug. At the end of a game, a menu is displayed over the top of the game canvas. This menu includes a form to submit score to leaderboard, with a text input field to enter a nickname. This input field was not working on desktop: clicking on the input and typing had no effect.

A screenshot of my tetris app’s game over screen. There is a form to input a nickname and submit score to the leaderboard.
My leaderboard form. Typing in the nickname field had no effect.

So I went down a rabbit hole of trying to identify the problem, and also rewriting the form component to make the code cleaner. After some time, the code was nicer to read, but the bug persisted.

The component looked a bit like this (very similar to textbook examples of basic forms using React Hooks):

function LeaderboardForm(props) {
const [nickname, setNickname] = useState('');

...

return (
<form>
...
<input value={nickname} onChange={setNickname} />
...
</form>
);
}

export default LeaderboardForm;

Curiously, I found that the form worked perfectly when it was the only component rendered. The bug only appeared when this component was rendered within the app. This was the clue I needed to identify the problem.

When I type in the input field, the main keyboard event handler gets called first. That handler calls event.preventDefault(), so the input's onChange handler never gets a chance.

The problem is with keyboard input, which is why the bug never appeared (in my testing) on mobile.

I had previously added checks so that game controls were disabled while the game was paused, or after a game over, but the event handlers were still called. The event handlers were just blocking the key events from getting to the input element, then returning.

The Fix

So to fix this bug, I want keyboard controls to be disabled while the leaderboard form is visible. The controls are only needed while the game is in play, so I tied them to that property, using a nice effect hook.

useEffect(() => {
if (props.playing) {
document.body.addEventListener('keydown', props.keyHandler);
} else {
document.body.removeEventListener('keydown', props.keyHandler);
}
}, [props.playing, props.keyHandler]);

Each time the game is paused, resumed or ended, the event listener gets removed or re-added.

Consider Every Case

Of course, this simple fix was too simple. One of the keyboard controls is p to pause and resume the game. Resuming the game is only a useful function when the game is paused, but there's no longer an event listener while the game is paused.

So add another event listener just for p which can always be enabled. That fixes the immediate problem, but now you can't type a nickname containing the letter p, because that starts a new game.

So we need another case to allow resuming the game while paused, but not a shortcut to resume after game over, because that might interfere with the leaderboard.

For simplicity and correctness, there is now no keyboard shortcut to start a new game after game over, but at least you can use any letter of the alphabet in your nickname.

That is the current version. And it works, for now.

Lessons Learned

  • Key presses can trigger many different actions. As soon as there are several different actions triggered by the same key, we need to be very careful about how they interact. A few weeks ago, I thought adding keyboard controls would just be boring plumbing. Today, I spent enough time on this bug that I decided to write a blog post.
  • I could have identified the problem sooner. The bug appeared after I made two relatively small changes yesterday, so it seems almost certain that those changes caused it. That could have helped focus my efforts. Even in the context of more complex change history, there are tools to help.
  • Testing is important. If I had perfect, comprehensive tests, I could have identified this bug yesterday, when it first introduced, before it was committed. But that doesn’t mean testing is perfect. My bug had no effect when the form component was rendered in isolation; a simple unit test would not have helped. While my tests in this project are admittedly sparse, I doubt an automated test of whether a particular text input element displayed text typed into it within the context of the rest of the app would have been near the top of my list.

You can play tetris using my app if you want to. If you really want to, you can tell me about any bugs you find.

--

--