JavaScript Promises part 1: Meet promises
28 Jun 2019
Promises are a great way to execute asynchronous tasks while maintaining shallow, readable code with the benefit of a structured API and proper error handling. In this three-part guide we'll meet them, see how we got here, and see how they work!
The problem (historically) 🔗
Promises came along to solve an age-old problem in JavaScript, namely Callback Hell, also referred to as the Pyramid of Doom.
Suppse you had a function, request()
, which fired off an AJAX request, accepting a URI, some data to send, and a callback that gets called on completion.
function request(uri, params, callback) {
let req = new XMLHttpRequest(),
data = new FormData();
req.open('post', uri);
for (let param in params) data.append(param, params[param]);
req.onload = callback;
req.send(data);
}
Note how our function doesn't return anything. Instead, what happens once the request completes is up to the callback we pass. We'll see how this changes, later, once we start using promises.
Suppose we then need to run three consecutive requests:
- Log in a user, based on an entered user/pass, and get back a user ID
- Resolve that user ID to some profile data about the user
- Fetch a list of events happening in the user's town
let params = {
user: document.querySelector('#user'),
pass: document.querySelector('#pass')
};
request('/login', params, data => {
let user_id = data.user_id;
request('/get-profile', {user_id: user_id}, data => {
let town = data.town;
request('/get-events', {location: town}, data => {
//finally, do something with events data
});
});
});
Urgh. See how we get ever deeper into nested callbacks the more requests we do? That's the Pyramid of Doom. There's other problems; like, we'd need to handle errors separately for each request. That's laborious.
How about if we don't care about the order of completion, and we have the requests run at the same time? Are things still as bad?
request('/foo', null, data => null);
request('/foo2', null, data => null);
//then do something with the accumulated data
OK, so no Pyramid of Doom this time. But how we do know when they're all complete? We'll need to build something manually to check continually when all requests complete and we can use their data.
let requestData = []; //container for request data
//fire off our two requests
request('/foo', null, data => {
requestsData.push(data));
checkIfDone();
});
request('/foo2', null, data => {
requestsData.push(data));
checkIfDone();
});
//func to track when both are done
function checkIfDone() {
if (requestData.length === 2) useData();
}
//func to use the requests' data, once both complete
function useData() {
//do cool stuff with data here
}
Urgh again. That's not fun. We shouldn't have to do this stuff manually. There, each request has to call a function whose job it is to check whether all requests are done.
Let's do things a much better way.
Meet promises 🔗
At their heart, promises are a means of controlling execution order and flow where asynchronous operations are involved. As MDN puts it, a promise:
...represents the eventual completion (or failure) of an asynchronous operation, and its resulting value.
So when we create a promise, we're creating an object that as yet has an unknown fate. It is initialised in an unresolved state, and it is then later resolved by being either fulfilled or rejected. Let's look at some syntax.
let prom = new Promise((resolve, reject) => {
let rand = Math.floor(Math.random() * 100000);
setTimeout(() => resolve(rand), 1000);
});
prom.then(num => console.log(num));
What's happening here is we're creating a new promise object, by instantiating its constructor, which takes as its only argument a function that is immediately executed. JavaScript automatically feeds to that function two callback functions - one to call when we want to fulfil the promise, and one to call should we wish to reject it.
You may wonder why I didn't call the first callback fulfill
rather than resolve
. In fact it's easy to be confused about the difference between fulfilment and resolution when it comes to promises. I'll discuss this in part 3, or you can check out this Medium article by Kesk Noran.
Another thing to note is that we have to use the new
keyword when creating a promise. Omitting new
will result in a JavaScript error.
Our function generates a random number then performs an asynchronous operation in the form of a timeout - after which we fulfil our promise with our number. Because we feed our number to resolveCallback()
, it's passed automatically to any then()
callbacks waiting for our promise to resolve.
Resolving promises from outside 🔗
Unlike jQuery deferred objects, promises can't be resolved from outside, since the callbacks to fulfill or reject them are, as we just saw, avaialble only inside the callback.
A common workaround is to declare placeholders for the callbacks outside, then overwrite them inside.
let resolveOuter, rejectOuter;
const prom = new Promise((resolve, reject) => {
resolveOuter = resolve;
rejectOuter = reject;
...
});
//now we can resolve the promise from outside its callback
Actually, as of ECMA 2024, we can go one better. This release of JavaScript introduced a new static method, Promise.withResolvers()
. The job of this method is to return all three parts of a promise - the promise itself, plus its two resolver callbacks - as an object. This means we can access the resolvers from outside.
const { promise, resolve, reject } = Promise.withResolvers();
//we have instant, outer access to the resolvers
I discuss this more, and show a fuller example, in this article over here.
Promise chaining 🔗
One of the cooloest things with promises is they can be chained. This is because promise methods such as then()
, as well as catch()
and finally()
which we'll meet a little later, themselves return a promise.
let prom = new Promise(resolve => resolve('Hi!'));
let then = prom.then(...);
console.log(then.toString()); //"[object Promise]"
JavaScript chaining - whether it's promises or jQuery - relies on the principle of each method in the chain returning an object with further chainable methods on it. So jQuery's chainable methods each return the base jQuery object, while promise methods return promises, meaning we can, in theory, chain together promises indefinately.
The role of then()
, catch()
and finally()
is to decide what happens once a promise is resolved (remember resolved could mean fulfilled OR rejected). Any return value they give is wrapped in the promise's fulfilment value (or rejection reason, as we'll see shortly).
Let's rework our earlier example, with three consecutive AJAX requests, to use chained promises. First, we'll need to modify request()
.
function request(uri, params) {
return new Promise(resolve => { //now returns something!
let req = new XMLHttpRequest(),
data = new FormData();
req.open('post', uri);
for (let param in params) data.append(param, params[param]);
req.onload = response => resolve(response);
req.send(data);
});
}
There's two key differences here. Firstly, our function now returns something - a promise. This means it can feed straight into our promise chain.
Secondly, it doesn't need to be passed a callback. Instead, it automatically resolves the promise once the request completes, passing along the AJAX response. This means we can handle the data within our promise chain, not in some callback we farm out to a function (the so-called Inversion of Control problem).
Thirdly, because request()
now returns a promise, we no longer have to kick off our chain by manually making a promise via new Promise()
. We just call request()
, and that gives us our first promise.
Let's see it in action.
request('/login', params) //remember @params is our user and pass
.then(data => request('/get-profile', {user_id: data.user_id}))
.then(data => request('/get-events', {location: data.town}))
.then(data => {
//finally, do something with events data
});
See how our code is more shallow now? And how we aren't dependant on callbacks passed to some remote function? This means no more Pyramid of Doom, and instead shallow, readable code fully under our control.
The key to this is then()
. Recall that then()
returns its own promise, and therefore acts as a junction in our flow. It is responsible both for receiving the data from the fulfilled request before it, and also, via its callback, kicking off the next request.
What about our other earlier example, where we didn't care about the completion order of our requests? Well, promises have us covered there too, via Promise.all()
, which we'll meet in part 2.
So this is all well and good. But what if something goes wrong? We'll look at error handling in part 2.
Summary 🔗
Promises are a means of handling asynchronous operations while maintaining shallow, readable code.
Promises represent an unresolved state initially, and are later resolved (by being fulfilled or rejected) depending on the outcome of the operation(s).
Since promise methods themselves return promises, wrapping any return value in them, promise chains can be created.
I hope you found this guide useful. While we've covered the nuts and bolts of promises, in part 2 I'll be looking at error handling and some of the wider features of the Promise API. Join me there!
Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!