JavaScript Promises - how to (not) use it

von Michael Hansen (Kommentare: 0)

How do you handle asynchronous operations in JavaScript? Working with callback function was the common way for years and it still feels like spending some time with a good old friend. But using callback functions do have some drawbacks (we will talk about later). Since ES2015 we have Promises. ES2017 will even simplify the work with asynchronous operations. So, if you didn't take a deeper look into Promises yet, you should. Promises can be a real game changer for your code - if you use them right.

Even if you don't plan to write Promises yet. A lot of frameworks and APIs start working with it, for example to simplify AJAX requests. So what does working with Promises look like? Let's assume that the following get function does something asynchronously. First of all with callback function:

get('story.json', function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
});

And the same example using a Promise:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
});

The get function only takes one parameter. It has a return value - a Promise. Promises have a then function. The function expects two parameters - yes, two callback functions. The first function handles the success of the asynchronous operation while the second function helps you to handle errors. So, why should you use Promises? Where are the advantages? You still have to keep up with two callback functions.

Welcome to the callback pyramid of death

Let's take a look on a more complex example to give you an idea:

Parse.User.logIn("user", "pass", {
    success: function () {
        openDatabase(function (db) {
            if (db) {
                var result = doSomethingFancy();
 
                results.save({key: value}, {
                    success: function (result) {

                    },
                    failure: function (error) {
                        errorHandler('handle error');
                    }
                });
            } else {
                errorHandler('handle another error');
            }
        },
        function (error) {
            errorHandler('always these errors');
        });
    },
    failure: function (error) {
        errorHandler('error handling is fun');
    }
});

And now let's have a look at (almost) the same example using Promises:

Parse.User.logIn("user", "pass").then(function (user) {
    return openDatabase();
}).then(function (db) {
    return doSomethinFancy();
}).then(function (results) {
    return results.save({key: value});
}).catch(function (reason) {
    // maybe catch an error in between
    errorHandler('handle error'); 
}).then(function (result) {
    // will be called even if errors happened before
 
}).catch(function (reason) {
    // final error handling
    errorHandler('handling other errors');
});

We talk about the code in detail later. Just focus on the much cleaner code and the way we handled errors just by chaining function calls to then and catch. Anyway, promises are not a guarantee for cleaner code! You need to get the idea of Promises. Otherwise you'll come back to messy code you wrote before in your good old 'callback function'-days.

Working with Promises

Maybe you feel unhappy now because we didn't even create one Promise yet. And before we can start creating our first Promise, we have to keep up with the wording. We talked about failure and success. Actually we should talk about a fulfilled or rejected Promise. We need to get a step back. What is a Promise? The Promise object represents the eventual completion (or failure) of an (asynchronous) operation, and its resulting value. And yes, the operation doesn't have to be asynchronous. A Promise can be in the following states:

  • pending - initial state before anything happens
  • fulfilled - the operation could be completed successfully
  • rejected - the operation couldn't be completed successfully
  • settled - it's not a real state. It just says that the operation isn't pending

Let's create our first Promise:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async...
 
  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  } else {
    // good practice to use an Error object here 
    reject(Error("It broke"));
  }
});

A Promise expects a function with two parameters; each being a callback function. When your (asynchronous) operation completes successfully, you should call the first callback function. You can pass any arguments with that function call, for example to return some results of your operation. The second function should be called on any error. It's best practice to pass an error object for better failure handling.

Now you can work on this Promise like you saw before:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

We also saw the then function with just one parameter and the catch function:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}).catch(function(err) {
  console.log(err); // Error: "It broke"
});

Calling the then function with one parameter, the function won't handle any errors and propagate the failure with the returned Promise. The catch function is just like calling the then function only with the second parameter. But why are you able to chain the calls to these functions? It's because the then and catch function always returns a Promise itself. What happens if the function don't return anything or an unhandled error raises inside of a Promise? As I said, the function always returns a Promise. So calling throw new Error('oh, no!'); would become a rejected Promise.

With this knowledge, did you get the slight difference between the last two examples? In the second example you will even catch the rejected Promise that will be returned of the then function.

There is so much more to learn about Promises. Let's have a short look at two more interesting functions:

Promise.all([p1, p2, p3, ... , pn]).then(function(resultArray) {
    // will be called when ALL Promises are fullfilled
    // result[0] := return value of p1, result[1] := return value of p2, ...
}).catch(function(reason) {
    // will be called immediately after one promise is rejected
});
 
Promise.race([p1, p2, p3, ... , pn]).then(function(resultArray) {
    // immediately called after one promise is fullfilled
    // result := return value of the fullfilled promise
}).catch(function(reason) {
    // will be called immediately after one promise is rejected
});

If you want to wait for the result of multiple independet operations, Promise.all() is your choice. Don't chain them. They can be executed at same time. Promise.race() can be useful to handle timeouts. The first Promise could be an AJAX request, while another one could reject after a fixed amount of time. So if your AJAX request doesn't respond in that time, your other Promise will.

So, what did you learn so far using Promises?

  • You getting closer to functional programming where functions have params and a return value.
  • Promises can be chained; improving readability.
  • You can handle failures in a block and don't have to repeat yourself. If you want to handle particular failures, you could use frameworks like Bluebird.
  • You can start several asynchronous independent operations at same time, waiting for the result of all of them before you continue your work.

One more thing...

Why do we need to write this new Promise(...) boilerplate code if you just want to have an asynchronous function?
With Promises you need to write:

function getJSON(url) {
    return new Promise(function(resolve, reject) {
        // do a thing, possibly async...
 
        if (/* everything turned out fine */) {
            resolve("Stuff worked!");
        } else {
            reject(Error("It broke"));
        }
    });
}

function foo() {
    getJSON('story.json').then(function(result) {
 
    }).catch(function(reason) {
        // error handling
    });
}

Lukily ES 2017 introduces async functions, making things easier:

async function getJSON(url) {
    // do a thing, possibly async...
 
    return "Stuff worked!";
}
 
function foo() {
    try {
        let result = await getJSON('story.json');
    } catch (e) {
        // error handling
    }
}

Maybe it's still too early to use async functions yet. Anyway, all the latest versions of the common browsers already support it [1]. At least Promises are already well supported and found their way into several frameworks and APIs.

[1] caniuse.com/#search=async

Zurück