JS Callbacks to Promises

This post is about converting old APIs like requestAnimationFrame to modern, Promise-based APIs. ⚠️ If you just want to see the code, scroll down.

Background on Promises

Promises are great. They represent a common way to react to asynchronous operations in JavaScript, e.g.:

const p = window.fetch('/some/url');
p.then((response) => response.json()).then((json) => {
console.info('got a response', json);
});

Nearly every modern JavaScript API operates in this way. The common Promise type lets us handle exceptions, compose operations, or perform multiple parallel tasks. 🤘

Async and await

And with the right combination of browsers (or a compilation step), we can even use the await keyword to get rid of callback hell, e.g.:

const response = await window.fetch('/some/url');
const json = await response.json();
console.info('got a response', json);

While cool, this isn’t much more than syntactic sugar over the first example. It also requires you to be inside what’s called an async function to start with, which returns a Promise, so eventually you have to deal with a real Promise.

➡️ If these concepts are new to you, be sure to read up on Promises and Async functions on Web Fundamentals.

Converting old APIs to Promises

There’s a few different types of platform APIs that have been made available in JavaScript over the years. Let’s start with simple callbacks.

1. Callbacks

The perennial favourite, setTimeout, can be converted to a Promise like this:

window.setTimeout(() => {
// do something after 1000ms
}, 1000);

Modern APIs like requestAnimationFrame, requestIdleCallback are even simpler — as they take no arguments. In both cases, the first callback argument (either a DOMHighResTimestamp or IdleDeadline object) is passed as the ‘resolved’ part of the Promise.

const promiseRequestAnimationFrame = () =>
new Promise((resolve) => window.requestAnimationFrame(resolve));
const promiseRequestIdleCallback = () =>
new Promise((resolve) => window.requestIdleCallback(resolve));

There’s a downside to these approaches, though. Promises aren’t cancelable—if you want to cancel ❌ a timeout or call cancelAnimationFrame, with this approach, you can’t. That might be fine, but there’s a few workarounds below.

2. Complex APIs with events

Various APIs let you set properties like onload, onerror etc to listen to their progress. There’s often more, but the load and error combination is the most common. Let’s start with adding a <script> tag to your page dynamically:

const insertScript = (path) =>
new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = path;
s.onload = () => resolve(s); // resolve with script, not event
s.onerror = reject;
document.body.appendChild(s);
});

What about reading files 📄 from the browser, e.g. a <input type="file">? FileReader uses callbacks, so let’s fix that:

const readFile = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file); // or a different mode
});

These idioms are all pretty similar, regardless of the API you’re wrapping.

3. Single-use listeners

Finally, a very neat example is to wait for something that’s happening on the page—such as a transition or animation event. Let’s say you have an element <div id="ball"> that moves with transition: transform 2s. If you set its style, the element will produce a transitionend event after 2 seconds.

We want to wait for that event to complete, but only once—Promises can’t be reused, so it wouldn’t make sense to listen for every move. You need to be sure to use the {once: true} argument to addEventListener, e.g.:

const waitForMove = (element, transform) =>
new Promise((resolve) => {
element.addEventListener(
'transitionend', () => resolve(), {once: true});
element.style.transform = transform;
});

The once argument is only supported in browsers from about 2017+ onwards. You can also swap it out for adding and removing a single event listener.

4. NodeJS-style APIs

While you might not see these in browsers, most NodeJS APIs can be simply wrapped up too. These are calls that take a callback object that typically have the signature function callback(errorOrNull, result) {...}.

Node 8 has the helper method util.promisify, which makes this section a bit redundant—just use that method. But for a simple example, let’s wrap up fs.readFile into a Promise:

const promiseRead = (path, options) =>
new Promise((resolve, reject) => {
fs.readFile(path, options, (error, data) => {
error ? reject(error) : resolve(data);
});

Cancelable Promises

As mentioned above, converting methods like setTimeout to a Promise has a downside—it’s not cancelable. Technically there’s no solution to this—Promises aren’t cancellable, despite a now-failed TC39 proposal. 😢

Naïvely, we can’t cancel the promise because setTimeout has two outputs (in the broadest sense): the ID of the timeout (which can be used when calling clearTimeout), and the method being called.

There’s an argument to be made here that if you’re aiming to use this syntax:

await promisifiedVersionOfThing();

…then actually cancelling it is unusual, because it will ‘stop’ execution.

Opinion time

Because the point of using await is to mask the fact that we’re asynchronous—removing callback hell—I want my method to complete at some point. If the execution just…stops, because the Promise is never resolved, that’s less-than-ideal. 💔

But if you really want…

There are a couple of options for enabling a Promise to be cancelable, but none of them are pretty. 🙀

1. Add a property to the returned Promise that allows cancelation by convention. This would look a bit like:

const promiseSetTimeoutWithCancel = (ms) => {
let id = 0;
const p = new Promise((resolve) => {
id = window.setTimeout(resolve, ms);
});
p.cancel = () => window.clearTimeout(id);
return p;
};

2. Return two arguments from your helper:

const promiseSetTimeoutReturnBoth = (ms) => {
let id = 0;
return {
promise: new Promise((resolve) => {
id = window.setTimeout(resolve, ms);
}),
cancel: () => window.clearTimeout(id),
};
};

3. Use a more complex abstraction and library, like Observables.

That’s it! You can read some more of my posts below, or follow me on Twitter, or whatever you like. I’m a post, not a cop. 📖🚨

Emoji by Emojityper.

Written by

My articles here are for posterity only. I no longer recommend or use Medium.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store