Sam Thorogood

Async Generators for User Input

It's possible to build JS, on the web, with a native-like event loop. This is thanks to async generators and the await keyword—are you sick of a twisty maze of addEventListener, timeouts and global state? Read on.

Background

Generators allow us to suspend normal program flow as the interpreter jumps to/from your yield statements:

function* generator() {
  yield 1;
  console.info('between');
  yield 2;
}
for (const value of generator()) {
  console.info(value);
}

This program will print out "1", "between", and "2". Used at face value, this primitive allows you to create something which vaguely looks like an array.

This isn't all, though: from Chrome 63 and friends, you can perform asynchronous work between each yield statement (to be fair, you could already yield a Promise). The syntax isn't too different, either: just add async to your function.

Event Loop

Most documentation about JS' event loop correctly identifies it as event-driven. This is the normal JS model—if you addEventListener, your handler is called, and is expected to complete synchronously.

Instead, let's aim for something more akin to a native event loop, which you could use like:

(async function run()) {
  for await (const event of loop) {
    if (event instanceof MouseEvent) {
      // If the user clicked on something, wait for their result.
      if (event.name === 'click' && event.target === button) {
        await getUserInput('Rate your experience:');
      }
    } else if (event.type === 'keydown') {
      // Submit the form
      if (event.key === 'Enter') {
        // TODO: ...
      }
    }
  }
}());

This is basically implementing a state machine—a DFA, where the states are controlled by user input. This is especially userful for complex user interactions—like forms, or games.

a train game with two input states: choosing an edge, and drawing a track

There's a few decisions you might have to make about the input, though.

As you're now consuming time—potentially asynchronously via await—to process each event, it's unlikely that your code will be able to handle every event as it arrives. For example, if you're processing click events but you do a network round-trip, a user might generate more clicks before the first event is done. This might be intentional, but you'll need to decide what's important to queue up for processing later.

What does the loop object look like, then? Well, you can build a generator and a helper to push events into it:

export function buildEventManager() {
  let resolve = () => {};
  const queue = [];

  // (there's no arrow function syntax for this)
  async function* generator() {
    for (;;) {
      if (!queue.length) {
        // there's nothing in the queue, wait until push()
        await new Promise((r) => resolve = r);
      }
      yield queue.shift();
    }
  }

  return {
    push(event) {
      queue.push(event);
      if (queue.length === 1) {
        resolve();  // allow the generator to resume
      }
    },
    loop: generator(),
  };
}

This is a bit of code, but it basically just has two parts and a connection between them. First, a push() method, which lets you control what events are handled, and pushes them into the queue. Secondly, a generator—which we run, and return as loop—which waits for events to appear, and uses yield to provide the next available one.

To use it purely to keep a queue of all the pointer events that occur, try this:

const {push, loop} = buildEventManager();

window.addEventListener('pointermove', push);
window.addEventListener('click', push);

(async function run() {
  for await (const event of loop) {
    console.info('mouse now at', event.screenX, event.screenY);
  }
}());

This simple example just queues everything, rather than trying to e.g., only provide the most recent movement event.

Not Just User Events

One of the benefits of a generic event loop is that we can process any sort of event we imagine, not just user-generated ones. For example, we could push some custom events and process them in your event loop inline with everything else:

window.setInterval(() => {
  push(new CustomEvent('tick'));
}, 1000);

const ro = new ResizeObserver(() => {
  push(new CustomEvent('resizeElement'));
});
ro.observe(someElement);

… of course, you're not just limited to custom events (push accepts any object), but this might match the rest of the inputs you're processing.

State Machines, Oh My

I mentioned that this native-like event loop helps us create state machines. If you just have a single event loop, that's not really true, because you might still have to manage global state yourself. Instead, you can actually use the loop many times.

Unfortunately, using for await (...) actually doesn't work here, as you're seemingly not able to use it more than once (I may need to read the ECMAScript spec to find out why). Instead, we can use a generator's .next() method:

(async function run() {
  for (;;) {
    const {value: event} = await loop.next();
    if (event.type !== 'click') {
      continue;
    }
    console.info('start line at', event.screenX, event.screenY);

    for (;;) {
      const {value: innerEvent} = await loop.next();
      if (innerEvent.type === 'click') {
        console.info('draw line to', innerEvent.screenX, innerEvent.screenY);
        break;
      }
    }
  }
}());

In this example, we wait for one click, then another. It's a simple example, but shows how you might build up some interesting state—you've started at the default state, then moved to the "waiting for a second point" state, then you're able to draw a line.

Digression

As a digression, while it's not an error to write something like:

element.addEventListener('click', async (event) => {
  const result = await (fetch('/foo').then((r) => r.json());
  // update the DOM
});

… it is basically an antipattern because the async keyword masks some possible issues:

  1. You could end up handling the event several times in parallel, e.g., submitting a form many times before it's complete
  2. Errors aren't caught anywhere (they would appear as an "unhandled promise rejection")
  3. Importantly, it appears like the event handler is synchronous and blocking, even though it's not

You can work around the 1st issue with something like cancellable async functions.

Conclusion

What I've covered here is an incredibly basic example of processing events in a queue. You'd want to inspire from these ideas and write something appropriate for your use-case: I know I will for my web-based games.

One key difference from actual native event loops is that on the web, we can't (and probably don't want to) turn off every bit of built-in behavior your browser provides for you. I'm not suggesting that you handle every keystroke in an <input type="text">, or overload every click event. But this lets you control perhaps a tricky component which transitions through a bunch of states, especially if it has complex user interactions—keyboard, mouse, and so on.

Finally, there's a lot of reading out there about state machines via async generators—that's great, and I'm glad to see many folks taking advantage of this idea. I've not seen a huge amount of writing about processing user events, though, and that's where I want you to consider the possibilities.