Complete Guide to Promise Chaining in Javascript

Complete Guide to Promise Chaining in Javascript

Introduction

There are three methods on the Promise.prototype object which are mentioned below:

Each of the above mentioned method returns a new promise. This allows us to create a chain of method calls. This chaining of prototype methods of the Promise object is referred to as promise chaining.

Let's take a look at an example of a promise chain:

fetch(/* some url */)
  .then(response => response.json())
  .then(result => console.log(result))
  .catch(error => console.log(error));

Calling fetch returns a promise, and we called then() method on the promise returned by the fetch function. As then method also returns a new promise, we can chain any of the methods on the Promise.prototype object, to the first then method call; in the above example, we have called the then method on the promise returned by the first then method call.

Promise chaining allows us to create a chain of asynchronous operations; each operation in the promise chain executes asynchronously, one after the other in a sequential manner. The result of one asynchronous operation is passed to the next operation in the chain.

To understand promise chaining, we need need to understand the three methods on the Promise.prototype object which are mentioned above. Understanding these methods will help us understand how we can pass the result of one asynchronous operation to the next in the promise chain.

then() Method

then method is used to register a callback function which is invoked asynchronously whenever the promise on which then method is called on - fulfils.

As mentioned before, then method also returns a new promise which fulfils or gets rejected depending on:

  • What happens to the promise on which then method is called on, and
  • What you do inside the callback function of the then method.

If the original promise on which then method is called on gets rejected, promise returned by the then method also gets rejected, provided that you only register a fulfilment callback function using the then method (later in this article, we will discuss another version of then method that also registers a rejection callback function).

If the original promise is fulfilled, then the promise returned by the then method depends on what happens inside its callback function:

  • If you return a non-promise value from the callback function, promise returned by the then method gets fulfilled with the return value of its callback function.

  • If you return a promise or a thenable object from the callback function, the promise returned by the then method gets resolved to the return value of its callback function.

  • If you throw any value from inside of the callback function of the then method, promise returned by the then method gets rejected with the rejection reason equal to the value thrown by the callback function.

Let us understand the above theory about the then method using the following example:

const p1 = fetch(/* some url */);

p3 = p1.then(response => response.json());

p4 = p3.then(result => console.log(result))

In the above example, there are four promises involved:

  1. p1 - returned by the fetch function
  2. response.json() returns a promise, lets call it p2
  3. p3 - returned by the then method called on p1, i.e. p1.then
  4. p4 - returned by the then method called on p3, i.e. p3.then

As mentioned above, promise returned by the then method depends on two factors:

  • Original promise on which then method is called on
  • Callback function passed to the then method

In the above example, let's assume that the promise p1 is fulfilled, so the fate of the promise p3 depends entirely on what happens inside the callback function of the first then method call, i.e. p1.then.

As the callback function of p1.then method returns the result of calling response.json() method which also returns a new promise (let's call it p2), the promise p3 is resolved to the promise p2. This means that the fate of the promise p3 depends on what happens to the promise returned by the response.json() method, i.e. p2.

If p2 is fulfilled, p3 also gets fulfilled with the same fulfilment value with which promise p2 fulfilled; if p2 is rejected, p3 also gets rejected with the same rejection reason with which p2 got rejected.

The idea of one promise resolving to another promise is key to understanding promise chaining.

Moving forward with our example, the promise returned by p3.then also depends on two factors mentioned above. It depends on what happens to the promise p3 and it also depends on what you do inside the callback function passed to the then method.

Let's assume that the promise returned by response.json(), i.e. p2 is fulfilled. This leads to the fulfilment of the promise returned by the p1.then method, i.e. p3.

As the promise p3 is fulfilled, promise returned by p3.then (let's call it p4) depends on what happens inside the callback function of p3.then. Since the callback function of p3.then implicitly returns undefined, the promise p4 is fulfilled with the value of undefined.

Following images show how fulfilment or rejection of any promise in the promise chain affects other promises in the chain.

Scenario where all promises are fulfilled: all promises in a promise chain fulfilled

Scenario where promise p1 gets rejected: one promise in a promise chain rejected

Scenario where promise p2 gets rejected: one promise in a promise chain rejected

Scenario where an error is thrown from the callback function of the p1.then method: error thrown from the callback function in a promise chain

then() method with two arguments

If the promise on which then method is called on gets fulfilled, the callback function passed to the then method as the first argument, is called.

then method with the single argument of fulfilment callback is commonly used but then method can also take a second argument which is a rejection callback function. This rejection callback function is called when the promise on which then method is called on, gets rejected.

Let's take a look at an example:

function fulfilmentCallback(result) {
  // some code
}

function rejectionCallback(result) {
  // some code
}

fetch(/* some url */)
  .then(fulfilmentCallback, rejectionCallback);

If the promise returned by the fetch function is rejected, rejectionCallback function will be invoked, allowing you to handle the promise rejection, just like you would using the catch method BUT there is one important difference between the catch method and the rejection callback of the then method: rejection callback of the then method is only invoked if the promise on which then method is called on, gets rejected - it isn't called if the promise returned by its then method gets rejected.

Consider the following example:

function fakeRequest() {
  return Promise.resolve('Hello World');
}

function fulfilmentCallback(fulfilmentValue) {
  console.log(fulfilmentValue);
  throw new Error("error!!");
}

function rejectionCallback(rejectionReason) {
  console.log(rejectionReason);
}

fakeRequest()
   .then(fulfilmentCallback, rejectionCallback);

In the above code example, we have a unhandled promise rejection because the fulfilmentCallback function throws an error that isn't caught because, as mentioned before, rejection callback function of then method is only invoked if the promise on which then method is called on, gets rejected - it's not invoked if the promise returned by the then method, to which the rejection handler is passed to as an argument, gets rejected.

If we chain the catch method to the then method, then we will not have the problem of unhandled promise rejection because the callback function of the catch method will be invoked if the promise returned by the then method gets rejected.

Use case for two argument version of then method

Two argument version of then method is useful if you want to handle a promise rejection of a particular promise in the promise chain - differently. For example, if you have a similar promise chain as shown below:

fetch(/* some url */)
  .then((response) => response.json())
  .then(...)
  .then(...)
  .catch(...);

If any of the promise in the above promise chain is rejected, it will eventually lead to the invocation of the catch method's callback function. We have a single rejection handler callback for all the promises in the promise chain.

What if we want to handle the promise returned by the fetch function differently? Maybe we want to supply a default response if the HTTP request fails. We could call catch method on the fetch call as shown below:

fetch(/* some url */)
  .catch((error) => {
      // return an object with the "json" method which
      // returns a default response
     return { 
         json() { 
            return { data: "default data" };
         } 
     };
  })
  .then(response => response.json())
  .then(...)
  .then(...)
  .catch(...);

or we could just use the two argument version of then method:

fetch(/* some url */)
  .then(
      response => response.json(),
      error => ({ data: "default data" })
  )
  .then(...)
  .then(...)
  .catch(...);

Earlier example with two catch method calls is a little awkward because we have to return an object with the json method whereas the second example with two argument version of then method is clearly more readable and makes more sense as compared to the first example.

catch() Method

The catch method is used to register a promise rejection callback that is invoked on promise rejection.

Like then method, catch method also returns a new promise which also like then method, depends on what happens to the promise on which catch method is called on and what you do inside the callback function passed to the catch method.

const p1 = fetch(/* some url */);

const p2 = p1.catch(error => { 
   /* handle the error */ 
});

In the above example, promise p2 that is returned by the catch method, depends on what happens to p1 and what you do inside its callback function.

If the promise p1 is fulfilled, callback function of the catch method isn't invoked and the promise it returns, i.e. p2 is also fulfilled with the same fulfilment value with which promise p1 fulfilled.

If the promise p1 is rejected, then the callback function of the catch will be invoked. Fate of the promise returned by the catch method will now depend on what you do inside its callback function.

  • If you return a promise or a thenable object from its callback function, promise p2 will get resolved to the return value of the callback function.

    This means that the promise p2 will fulfil if the thenable returned by the callback function is fulfilled. catch method in a promise chain

    If the thenable gets rejected, p2 will also get rejected with the same rejection reason. catch method in a promise chain

  • If you return a non-promise value from the callback function, promise p2 will get fulfilled with that returned value as the fulfilment value.

  • If you don't return anything, promise p2 will get fulfilled with the value undefined.

  • If you throw any value or an error from the callback function, p2 will get rejected with the thrown value as the rejection reason.

As mentioned above, returning a non-promise value or retuning nothing from the callback function of the catch method fulfils the promise returned by the catch method. This can implicitly convert promise rejection into a promise fulfilment.

Implicit conversion of promise rejection into promise fulfilment is shown in the image below:

Implicit conversion of promise rejection into promise fulfilment

finally() Method

The finally method is similar to the finally block in a try-catch block and its callback function is invoked on promise fulfilment as well as on promise rejection.

It can be used to do some clean-up work like hiding a loading spinner after an asynchronous operation. In other words, if there's some code that you want to execute irrespective of promise rejection or promise fulfilment, instead of duplicating that piece of code inside the then method and the catch method, place that code inside the callback function of the finally method.

Example:

fetch(/* some url */)
  .then(...)
  .then(...)
  .catch(...)
  .finally(() => { 
    /* hide the loading spinner */
  });

finally method, like then and catch, also returns a promise that like then and catch, also depends on what happens to the promise on which finally method is called on and what you do inside its callback function BUT there are limitations on what its callback function can or can't do. These limitations are discussed below.

Promise Fulfilment and the finally Method

Unlike the callback function of the then method, finally method can't affect the promise fulfilment passing through it.

Let's understand this with the help of an example:

function foo() {
  return Promise.resolve('Hello World')
    .then((result) => {
       console.log('inside then method in foo function');
       console.log(result);
       return 'return value of then method in foo function';
    });
}

foo()
  .then(result => {
    console.log('inside then method');
    console.log(result);
  });

What output do you expect?

Click here to see the output inside then method in foo function
Hello World
inside then method
return value of then method in foo function

The callback function of then method outside the foo function is passed the return value of then method inside the foo function. This is because the foo function returns the promise returned by the then method.

In the above example, if we replace the then method inside the foo function with the finaly method, we will get a different output.

function foo() {
  return Promise.resolve('Hello World')
    .finally(() => {
      console.log('inside finally method');
      return 'return value of finally method';
   });
}

foo()
  .then(result => {
    console.log('inside then method');
    console.log(result);
  });

What output do you expect?

Click here to see the output inside finally method
inside then method
Hello World

Notice the difference between the output of the two examples above: in the earlier example, then method outside the foo function was passed the return value of the then method inside the foo function BUT in the second example, then method outside the foo function is passed the promise fulfilment value of Promise.resolve method; return value of the finally method is completely ignored!

This is what i meant when i earlier said that finally method can't affect the promise fulfilment passing through it. The return value of the callback function of the finally method other than a thenable that gets rejected, is completely ignored.

Changing the Rejection Reason

If the finally method is called on a promise that gets rejected, its callback is invoked and if its callback function throws an error or returns a thenable that gets rejected, promise returned by the finally method also gets rejected and the rejection reason of the original promise is shadowed by the rejection reason of the promise returned by the finally method.

Following example shows this behaviour in action.

function foo() {
  return Promise.reject(new Error("error!"))
    .finally(() => {
       console.log('inside finally method');
       throw new Error("error from the finally method")
    });
}

foo()
  .then(result => {
     console.log('inside then method');
     console.log(result);
  })
  .catch(error => {
     console.log('inside catch method');
     console.log(error.message);
  });

What output do you expect?

Click here to see the output inside finally method
inside catch method
error from the finally method

Note that the callback function of the catch method logged the error message from the error thrown by the callback function of the finally method; error thrown by the finally method's callback function shadowed the error from the original promise, i.e. Promise.reject(...).

Changing Promise Fulfilment into Promise Rejection

Like the callback function of the catch method, callback function of the finally method can change the promise fulfilment into promise rejection. It can do this by:

  • Throwing an error from its callback function
  • Returning a thenable that gets rejected

Example:

function foo() {
  return Promise.resolve("Hello World")
    .finally(() => {
      console.log('inside finally method');
      throw new Error("error from the finally method");
   });
}

foo()
  .catch(error => {
    console.log('inside catch method');
    console.log(error.message);
  });
Click here to see the output inside finally method
inside catch method
error from the finally method

In the above example, promise returned by the Promise.resolve is fulfilled but the callback function of the finally method change the promise fulfilment into promise rejection by throwing an error. Error thrown from the callback function rejected the promise returned by the finally method.

In the above example, instead of throwing an error from the callback function of the finally method, we can also change promise fulfilment into promise rejection by returning a promise that gets rejected.

In the above example, try replacing the following statement:

throw new Error("error from the finally method")

with the following:

return Promise.reject(new Error("error from the finally method"))

You will notice that same output as before.

Delaying Promise fulfilment

Callback function of the finally method can also delay the promise fulfilment by returning a thenable that is ultimately fulfilled.

Example:

// fulfil the promise after
// specified number of seconds
function sleep(seconds) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("promise returned by finally");
    }, seconds * 1000);
  });
}

function foo() {
  return Promise.resolve("Hello World")
    .finally(() => {
      console.log('inside finally method');
      return sleep(2);
   });
}

foo()
  .then(result => {
    console.log('inside then method');
    console.log(result);
  });
Click here to see the output inside finally method
(after 2 seconds delay)
inside then method
Hello World

In the above example, promise returned by the finally is resolved to the promise returned by its callback function.

Promise returned by the callback function ultimately gets fulfilled after 2 seconds; until those two seconds are passed and the promise returned by the callback function of the finally method is fulfilled, finally method delays the invocation of the then method and, as discussed earlier, it can't affect the promise fulfilment - then method is passed the fulfilment value of the promise from Promise.resolve, instead of the promise returned by the callback function of the finally method.

Promise Chaining Examples

Now that we have discussed the three methods on the Promise.prototype object, let us take a look at some promise chaining examples. This will give you a chance to test everything you learned above.

Example 1:

 Promise.resolve("Hello World")
  .then(result => {
    console.log(result);
    return "foo";
  })
  .then(result => {
    console.log(result);
    throw new Error("error");
  })
  .then(result => {
    console.log(result);
  })
  .catch(error => console.log(error.message));

What output do you expect?

Click here to see the output Hello World
foo
error

Following image shows the execution of the code above: promise chaining example

Example 2:

Promise.reject(new Error("error!"))
  .then(
    (result) => console.log(result),
    (error) => {
      console.log(error.message);
      return "hello world";
    }
  )
  .then(result => {
    console.log(result);
  })
  .then(result => {
    console.log(result);
  });

What output do you expect?

Click here to see the output error!
hello world
undefined

Following image shows the execution of the code above: promise chaining example

Example 3:

Promise.resolve(100)
  .then(result => {
    console.log(result);
    return 200;
  })
  .finally(() => 300)
  .then(result => {
    console.log(result);
  });

What output do you expect?

Click here to see the output 100
200

Following image shows the execution of the code above: promise chaining example

Example 4:

Promise.resolve(100)
  .then(result => {
    console.log(result);
    return 200;
  })
  .finally(() => {
    const err = new Error("error from finally method");
    return Promise.reject(err);
  })
  .catch(error => {
    console.log(error.message);
  });

What output do you expect?

Click here to see the output 100
error from finally method

Following image shows the execution of the code above: promise chaining example

Summary

This article explained the three methods on the Promise.prototype object: then, catch, and the finally method. Each other these methods returns a promise that depends on two factors:

  • Promise on which any of these methods is called on.

  • What you do inside the callback function passed to these methods.

Understanding these methods is essential to understanding promise chaining and the fact that the promise returned by each of these methods can get resolved to the promise returned by the callback function of these methods can help you avoid creating unnecessarily-nested promise chains.

Did you find this article valuable?

Support Yousaf Khan by becoming a sponsor. Any amount is appreciated!