Promises are a central mechanism for handling asynchronous code in JavaScript. You will find them in many JavaScript libraries and frameworks, where they’re used to manage the results of an action. The fetch()
API is one example of promises at work. As a developer, you might not be familiar with creating and using promises outside of an existing product, but it’s surprisingly simple. Learning how to create promises will help you understand how libraries use them. It also puts a powerful asynchronous programming mechanism at your disposal.
Asynchronous programming with promises
In the following example, we’re using a Promise
to handle the results of a network operation. Instead of making a network call, we just use a timeout:
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = "This is the fetched data!";
resolve(data);
}, 2000);
});
}
const promise = fetchData();
promise.then((data) => {
console.log("This will print second:", data);
});
console.log("This will print first.");
In this code, we define a fetchData()
function that returns a Promise
. We call the method and hold the Promise
in the promise
variable. Then we use the Promise.then()
method to deal with the results.
The essence of this example is that the fetchData()
call happens immediately in the code flow, whereas the callback passed into then()
only happens after the asynchronous operation is complete.
If you look inside fetchData()
, you’ll see that it defines a Promise
object, and that object takes a function with two arguments: resolve
and reject
. If the Promise
succeeds, it calls resolve
; if there’s a problem, it calls reject
. In our case, we simulate the result of a network call by calling resolve
and returning a string.
Oftentimes, you’ll see the Promise
called and directly handled, like so:
fetchData().then((data) => {
console.log("This will print second:", data);
});
Now let’s think about errors. In our example, we can simulate an error condition:
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.5) {
reject("An error occurred while fetching data!");
} else {
const data = "This is the fetched data!";
resolve(data);
}
}, 2000);
});
}
About half of the time, the promise in this code will error out by calling reject()
. In a real-world application, this could happen if the network call failed or the server returned an error. To handle the possibility of failure when calling fetchData()
, we use catch()
:
fetchData().then((data) => {
console.log("That was a good one:", data);
}).catch((error) => {
console.log("That was an error:", error)
});
If you run this code several times, you’ll get a mix of errors and successes. All in all, it’s a simple way to describe your asynchronous behavior and then consume it.
Promise chains in JavaScript
One of the beauties of promises is that you can chain them together. This helps avoid deeply nested callbacks and simplifies nested asynchronous error handling. (I’m not going to muddy the waters by showing an old-fashioned JavaScript function-with-argument-callback. Take my word for it, that gets messy.)
Leaving our fetchData()
function as it is, let’s add a processData()
function. The processData()
function depends on the results of fetchData()
. Now, we could wrap the processing logic inside the return call from fetchData()
, but promises let us do something much cleaner:
function processData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const processedData = data + " - Processed";
resolve(processedData);
}, 1000);
});
}
fetchData()
.then((data) => {
console.log("Fetched data:", data);
return processData(data);
})
.then((processedData) => {
console.log("Processed data:", processedData);
})
.catch((error) => {
console.error("Error:", error);
});
If you run this code several times, you’ll notice that when fetchData()
succeeds, both then()
methods are called correctly. When fetchData()
fails, the whole chain short circuits and the ending catch()
are called. This is similar to how try/catch blocks work.
If we were to put the catch()
after the first then()
, it would be responsible only for fetchData()
errors. In this case, our catch()
will handle both the fetchData()
and processData()
errors.
The key here is that fetchData()
‘s then()
handler returns the promise from processData(data)
. That is what allows us to chain them together.
Run no matter what: Promise.finally()
Just like try/catch gives you a finally()
, Promise.finally()
will run no matter what happens in the promise chain:
fetchData()
.then((data) => {
console.log("Fetched data:", data);
return processData(data);
})
.then((processedData) => {
console.log("Processed data:", processedData);
})
.catch((error) => {
console.error("Error:", error);
})
.finally(() => {
console.log("Cleaning up.");
})
The finally()
is useful when you need to do something no matter what happens, like close a connection.
Fail fast: Promise.all()
Now let’s consider a situation where we need to make multiple calls simultaneously. Let’s say we need to make two network requests and we need the results of both. If either one fails, we want to fail the whole operation. Our chaining approach above could work, but it’s not ideal because it requires that one request finishes before the next begins. Instead, we can use Promise.all()
:
Promise.all([fetchData(), fetchOtherData()])
.then((data) => { // data is an array
console.log("Fetched all data:", data);
})
.catch((error) => {
console.error("An error occurred with Promise.all:", error);
});
Because JavaScript is single-threaded, these operations are not truly concurrent but they are much closer. In particular, the JavaScript engine can initiate one request and then start the other while it is still in flight. This approach gets us as close to parallel execution as we can get with JavaScript.
If any one of the promises passed to Promise.all()
fails, it’ll stop the whole execution and go to the provided catch()
. In that way, Promise.all()
is “fail fast.”
You can also use finally()
with Promise.all()
, and it will behave as expected, running no matter how the set of promises pans out.
In the then()
method, you’ll receive an array, with each element corresponding to the promise passed in, like so:
Promise.all([fetchData(), fetchData2()])
.then((data) => {
console.log("FetchData() = " + data[0] + " fetchMoreData() = " + data[1] );
})
Let the fastest one win: Promise.race()
Sometimes you have several asynchronous tasks but you only need the first one to succeed. This could happen when you have two services that are redundant and you want to use the fastest one.
Let’s say fetchData()
and fetchSameData()
are two ways to request the same information, and they both return promises. Here’s how we use race()
to manage them:
Promise.race([fetchData(), fetchSameData()])
.then((data) => {
console.log("First data received:", data);
});
In this case, the then()
callback will receive only one value for data—the return value of the winning (fastest) Promise
.
Errors are slightly nuanced with race()
. If the rejected Promise
is the first to happen, then the whole race ends and catch()
is called. If the rejected promise happens after another promise has been resolved, the error is ignored.
All or none: Promise.allSettled()
If you want to wait for a collection of async operations to all complete, whether they fail or succeed, you can use allSettled()
. For example:
Promise.allSettled([fetchData(), fetchMoreData()]).then((results) =>
results.forEach((result) => console.log(result.status)),
);
The results
argument passed into the then()
handler will hold an array describing the outcomes of the operations, something like:
[0: {status: 'fulfilled', value: "This is the fetched data!"},
1: {status: 'rejected', reason: undefined}]
So you get a status field that is either fulfilled
or rejected
. If it is fulfilled (resolved), then the value will hold the argument called by resolve()
. Rejected promises will populate the reason
field with the error cause, assuming one was provided.
Coming soon: Promise.withResolvers()
The ECMAScript 2024 spec includes a static method on Promise
, called withResolvers()
. Most browsers and server-side environments already support it. It’s a bit esoteric, but Mozilla has a good example of how it’s used. The new method allows you to declare a Promise
along with the resolve
and reject
functions as independent variables while keeping them in the same scope.
Conclusion
Promises are an important and handy aspect of JavaScript. They can give you the right tool in a variety of asynchronous programming situations, and they pop up all the time when using third-party frameworks and libraries. The elements covered in this tutorial are all the high-level components, so it’s a fairly simple API to know.
Copyright © 2024 IDG Communications, Inc.