Mastering the Art of Promises in Javascript: A Step-by-Step Guide

Mastering the Art of Promises in Javascript: A Step-by-Step Guide

Introduction

Asynchronous programming is often a tricky topic in web development.

Asynchronous operations allow the computer to "move on" and complete other tasks while the asynchronous operation completes. Asynchronous programming is a way to ensure that time-consuming tasks don't stop all other programs.

You can find many examples of asynchronicity every day in our daily lives. Asynchronous operations, such as washing dishes in a dishwasher or washing clothes in a washing machine, are just a few examples of asynchronicity we see daily. We can complete other chores while the dishwasher is washing our dishes.

Web development similarly uses asynchronous operations. JavaScript lets us execute other tasks while we wait to finish operations like querying a database or making a network request.

This lesson will explain how JavaScript uses the Promise object introduced with ES6 to handle asynchronicity. Let's get started!

What is a Promise?

Promises are objects that indicate the result of an asynchronous operation. A Promise object can exist in any of three states.

  • Pending: This is the initial state of the operation. It has not been completed.

  • Fulfilled: This operation is completed successfully. The Promise now has a fixed value. For example, a promise may resolve with a JSON object as its value.

  • Rejected means that the operation was canceled and that the Promise was canceled. This reason is typically an Error.

All promises are eventually fulfilled. This allows us to develop logic to decide if the Promise is accepted or not.

Constructing a Promise Object

Let’s construct a promise! To create a new Promise object, we use the new keyword and the Promise constructor method:

const executeFn = (resolve, reject) => { };
const myPromise = new Promise(executeFn);

The Promise constructor takes a function parameter, the executeFn function. This function runs automatically when it is called. The executeFn function usually starts an asynchronous operation that dictates how the Promise should settle.

executeFn functions have two parameters. These function parameters are commonly referred to as reject() and resolve() functions. The programmer doesn't define the resolve() or reject() functions. JavaScript will pass its resolve() and reject() functions to the executor function when the Promise constructor is run.

  • A function that takes one argument is called resolve. Under the hood, resolve(), if invoked, will change the status of the Promise from pending to fulfilled, and the Promise's resolved value will be set at the argument passed to resolve().

  • Reject is a function that accepts an error or reason as an argument. If rejected() is invoked, the Promise's status will be changed from pending to reject. The Promise's rejection reason will then be set to reject().

Let's take a look at an executor function within a Promise constructor.

const executeFn = (resolve, reject) => {
  if (someCondition) {
      resolve('I resolved!');
  } else {
      reject('I rejected!');
  }
}
const myFirstPromise = new Promise(executeFn );

Let's take a look at what's going on above.

  • We declare a variable myFirstPromise

  • myFirstPromise was constructed using Promise (), which is the Promise constructor method.

  • executeFn() can be passed to the constructor. It has two parameters, resolves and rejects.

  • If someCondition is true, we invoke resolve() using the string "I resolved!"

  • If so, we invoke reject() using the string "I rejected!"

MyFirstPromise, for example, resolves or rejects based upon a simple condition. However, promises are settled based on the results of asynchronous operations in practice. A database request might be fulfilled using data from a query or rejected with an error thrown. To make it easier to understand their workings, we will create promises that resolve synchronously.

The setTimeout() Function

While it is helpful to know how to build a promise, most importantly, you will need to be able to use promises to make them. Instead of constructing promises, you will be dealing with Promise objects that are returned to you by an asynchronous operation. These promises will be pending at first, but they will settle in the end.

We'll continue to simulate this by offering functions that return promises and settle after a certain time. We'll use setTimeout() to accomplish this. setTimeout() can be described as a Node API. A similar API is available in web browsers. It uses callback functions for scheduling tasks that will be completed after a delay. setTimeout() takes two parameters: a callback function and a delay in milliseconds.

const delayedMsg = () => {
  console.log('Hi! This is an asynchronous message!');
};
setTimeout(delayedMsg, 3000);

We invoke setTimeout() here with the callback functions delayedMsg(), 3000. After at least three seconds, delayedMsg() is invoked. Why is it "at most" three seconds but not exactly three?

The delay is done asynchronously. This means that the rest of our program will continue to execute during the delay. The event loop is asynchronous JavaScript. Three seconds later, delayedMsg() is added to a line of code that is waiting to be executed. Any synchronous code in the program must be run before it can run. The next step is to execute any code that's in front of it. It could take up to three seconds for delayedMsg() to be executed.

Let's take a look at how setTimeout() will be used to build synchronous promises.

const returnPromise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(( ) => {resolve('I resolved!')}, 1000);
  });
};
const prom = returnPromise();

In the example code, we invoked returnPromise() which returned a promise. We assigned that Promise to the variable prom. Similar to the asynchronous promises you may encounter in production, prom will initially have a status of pending.

Consuming Promises

Although the initial state of an unasynchronous promise is still in flux, we can guarantee it will eventually settle. How can we tell the computer what should occur next? The .then() method is a useful way to tell the computer what should happen when you have promised objects.

.then() can be a higher-order function. It takes two callback functions to work. These callbacks are called handlers. The appropriate handler will be invoked when the Promise is settled.

  • OnFulfilled is sometimes used to refer to the first handler. It should include the logic for the Promise being fulfilled.

  • OnRejected is sometimes known as the second handler. It should contain the logic that will reject the Promise.

You can invoke.then() using either one, both, or none of the handlers! This flexibility allows us to be flexible but can lead to difficult debugging. If the correct handler is missing, instead of throwing an exception, .then() will return a promise with the same settled values as the Promise it was called upon. It always returns a promise, which is an important characteristic of .then().

Success and Failure Callback Functions

We invoke .then() to handle a "successful" or a promise that has been fulfilled and pass in a success handler function.

const prom = new Promise((resolve, reject) => {
  resolve('Hello World!');
});
const handleSuccess = (resolvedValue) => {
  console.log(resolvedValue);
};
prom.then(handleSuccess); // Prints: Hello World!'

Let's look at the code in question.

  • Prom is a promise that will bring you Hello World! '.

  • We define handleSuccess() as a function that prints the argument passed to it.

  • We invoke prom's function.then() passing in our handleSuccess() function.

  • Because prom resolves handleSuccess() invokes prom's resolved value, "Hello World!," so Hello World!" is logged to the console.

We won't be able to tell if a promise will solve or fail, so we'll need the logic. Both a success and failure callback can be passed to .then().

let prom = new Promise((resolve, reject) => {
  let num = Math.random();
  if (num < 0.5 ){
    resolve(`The number is less than 0.5`);
  } else {
    reject(`The number is greater than 0.5`);
  }
});
const handleSuccess = (resolvedValue) => {
  console.log(resolvedValue);
};
const handleFailure = (rejectionReason) => {
  console.log(rejectionReason);
};
prom.then(handleSuccess, handleFailure);

Let's look at the code in question.

  • Prom is a promise that will be randomly resolved with The number is less than 0.5 Prom is a promise that you can randomly either fulfill with The number is less than 0.5 or reject with The number is greater than 0.5

  • Two handler functions are passed to .then(). The first function will be invoked by The number is less than 0.5 If the Promise is fulfilled, the first will be invoked by the number is less than 0.5 If the Promise is rejected, it will be invoked by The number is greater than 0.5

Notice: The success callback can also be called the onFulfilled function or the "success handling function." Sometimes, the failure callback is called the "failure handling function" or onRejected function.

Let's make some failure calls!

Catch() with Promises

Separation of Concerns is a way to create cleaner code. Separation means dividing code into sections that each handle a particular task. This allows us to quickly navigate through our code and find the right place to go if something isn’t working.

If no handler was given, .then() returns a promise with the same settled values as the Promise it was called upon. This implementation allows us to separate our resolved logic and our rejected logic. Instead of passing them both to one.then() we can chain a second.then() without a failure handler to another.then() success handler, and both cases will be handled.

prom
  .then((resolvedValue) => {
    console.log(resolvedValue);
  })
  .then(null, (rejectionReason) => {
    console.log(rejectionReason);
  });

JavaScript does not mind whitespace, so we use a common convention to place each piece of the chain on a separate line. We can also use a promise function called .catch() to make the code even easier to read.

Only one argument is required for the .catch() function, onRejected. This function will invoke the failure handler with the reason for rejection in the event of a rejected promise. You can use .catch() to achieve the same result as a.then() without a failure handler.

Let's take a look at an example that uses.catch()

prom
  .then((resolvedValue) => {
    console.log(resolvedValue);
  })
  .catch((rejectionReason) => {
    console.log(rejectionReason);
  });

Let's look at the code in question.

  • Prom is a promise that randomly resolves with "The number is less than 0.5" Prom is a promise that randomly resolves with “The number is less than 0.5” or is rejected by 'The number is greater than 0.5 '.

  • We pass a success handler on to .then(), and a fail handler on to.catch().

  • If the Promise is fulfilled, .then() will invoke its success handler with 'YaThe number is less than 0.5'.

  • If the Promise is rejected,.then() returns a promise with the exact same rejection reason as the original Promise..catch()'s fail handler will also be invoked with the rejection reason.

Chaining Multiple Promises

Multiple operations that depend on each other or must be executed in a specific order are one common feature of asynchronous programming. One request might be made to a database, and the data we receive will be used to make another request. Let's take another example: washing clothes.

Then, we take the dirty clothes and place them in the washer. Once the clothes have been washed, we can put them in the dryer. If the clothes have dried after the dryer has run, we can fold them up and put them away.

Composition is the process of linking promises together. Composition is a key concept in promises! Here is a coded promise chain:

firstPromiseFunction()
.then((firstResolveVal) => {
  return secondPromiseFunction(firstResolveVal);
})
.then((secondResolveVal) => {
  console.log(secondResolveVal);
});

Let's look at the following example.

  • We invoke a function firstPromiseFunction() which returns a promise.

  • We invoke .then() using an anonymous function as our success handler.

  • Inside the success handler, we return a new promise-- the result of invoking a second function, secondPromiseFunction(), with the first Promise's resolved value.

  • To handle the logic of the second promise settling, we invoke a second.then()

  • We have a success handler inside that .then(). This logs the console's resolution value for the second Promise.

In order for our chain to work properly, we had to return the promise secondPromiseFunction(firstResolveVal). This made sure that the secondPromiseFunction(firstResolveVal) returned the promise secondPromiseFunction(), and not the default return of a promise with the same settled amount as the initial.

Avoiding Common Mistakes

Promise composition makes code much easier to read than its predecessor, the nested calling syntax. It is still possible to make mistakes. We'll be looking at two common errors in promise composition.

One mistake: You make nesting promises and do not chain them.

returnsFirstPromise()
.then((firstResolveVal) => {
  return returnsSecondValue(firstResolveVal)
    .then((secondResolveVal) => {
      console.log(secondResolveVal);
    })
})

Avoiding Common Mistakes

Promise composition makes code much easier to read than its predecessor, the nested calling syntax. It is still possible to make mistakes. We'll be looking at two common errors in promise composition.

One mistake: You make nesting promises and do not chain them.

Let's look at the code above.

  • We invoke returnsFirstPromise(), which returns a promise.

  • With a success handler, we invoke .then()

  • In the success handler, we invoke returnsSecondValue() using firstResolveVal, which will return a new promise.

  • To handle the logic of the second Promise, we invoke a second .then().

  • We have a success handler inside that second .then(). This logs the second Promise's resolved value back to the console.

Instead of having a clear chain of promises, we have nested each logic within the logic of another. Imagine if you were managing five to ten promises.

Mistake 2: Forgetting to return a promise.

returnsFirstPromise()
.then((firstResolveVal) => {
  returnsSecondValue(firstResolveVal)
})
.then((someVal) => {
  console.log(someVal);
})

Let's look at the following example.

  • We invoke returnsFirstPromise(), which returns a promise.

  • With a success handler, we invoke .then()

  • We make our second Promise to the success manager but forget to fulfill it!

  • A second .then() is invoked. This is supposed to handle the logic for the second Promise. However, since we didn't return, this .then() is invoked with a promise of the same settled value.

Using Promise.all()

The proposed composition can be a powerful way to deal with situations in which synchronous operations are dependent on each other or where the execution order is important. What happens if multiple promises are being made, but the order is not important? Let's look at cleaning from a different perspective.

To consider our house clean, it is necessary for our clothes to dry and our trash cans to be empty. The dishwasher must also run. All of these tasks must be completed, but not in a particular order. They should all be happening simultaneously, as they are all being done asynchronously.

Concurrency is a combination of multiple synchronous operations that occur simultaneously to maximize efficiency. This is possible with Promise.all().

Promise.all() can accept a variety of promises as an argument and return a single promise. One Promise can be fulfilled in either of the following ways:

  • Promise.all() returns a single promise if each Promise in an argument array resolves.

  • If an argument array promise is rejected, Promise.all() will immediately reject it with the reason why. This is often referred to by the term "failing fast".

Let’s look at a code example:

let myPromises = Promise.all([returnsPromOne(), returnsPromTwo(), returnsPromThree()]);
myPromises
  .then((arrayOfValues) => {
    console.log(arrayOfValues);
  })
  .catch((rejectionReason) => {
    console.log(rejectionReason);
  });

Let's take a look at what's going on:

  • We declare myPromises to be invoked Promise.all().

  • We invoke Promise.all() using an array of three promises - the returned values for functions.

  • If each Promise is successfully resolved, we invoke .then() using a success handler.

  • If any promises are rejected, we invoke.catch() using a failure handler.

To summarize, Promises in JavaScript are a way to handle asynchronous operations and avoid callback hell. They provide a way to organize and chain together multiple asynchronous operations in a clean and readable way.

Promises have three states: pending, fulfilled, and rejected. When a Promise is fulfilled or rejected, it returns a value or error message that can be passed to the next Promise in the chain using the .then() and .catch() methods.

To create a Promise, you can use the new Promise() constructor and pass in a function with two parameters: resolve and reject. Within the function, you can perform your asynchronous operation and call resolve() or reject(), depending on whether the operation was successful or not.

Promises are a powerful tool in modern JavaScript and are commonly used in web development. By mastering Promises, you can write cleaner, more efficient code and build more robust applications.