JavaScript Promises - Introduction to Asynchronous JavaScript

·

9 min read

Introduction

JavaScript is a programming language that is used to create dynamic and interactive web applications. As you must have seen or read by now - JavaScript is typically synchronous. This means that each line of code you write gets executed sequentially - one by one, following the order. Now, this can be limiting when it comes to the execution of tasks that take a long time to complete or when working with external resources like servers or databases.

Asynchronous JavaScript is a way to handle code that needs to perform tasks without blocking the main thread of execution. This means that the code can continue to run while the task is being completed in the background, allowing the user interface to remain responsive and interactive.

While asynchronous codes can commonly be handled with the usage of callbacks, this can become complex and difficult to maintain in large projects. Promises provide a cleaner and more intuitive way to handle asynchronous code in JavaScript, making the code easier to read, write, and maintain. Promises are a difficult concept even for experienced developers - so don't worry if you are feeling overwhelmed at first. In this blog, we'll explore in detail how promises work and how they can be used to write more efficient and reliable JavaScript code. So, let's dive in!

Synchronous vs. Asynchronous JavaScript

Synchronous JavaScript

In JavaScript, code is typically executed synchronously, which means that each line of code is executed in order, one at a time. Let's look at this example -

console.log("Task 1 completed");
console.log("Task 2 completed");

/* Result :
    Task 1 completed
    Task 2 completed
*/

When we run this code - the first line will be executed and printed first and then the second line will get printed in the console. To understand how this can affect us in real-time, let's modify this a bit -

let loopCount = 0;
console.log("Task 1 completed");
for(let i=0; i< 10000; i++){
    loopCount += 1;
}
console.log(`Loop is executed ${loopCount} times`);
console.log("Task 2 completed");

/* Result :
    Task 1 completed
    <Notice the delay here>
    Loop is executed 10000 times
    Task 2 completed
*/

Now when we run this code, the first statement gets printed and then the code execution gets "blocked" - as it encounters a for loop which is supposed to run 10000 times. Now we need to wait until the for loop finishes executing and then only the loop count gets printed, and the execution continues. This can become much more complicated and slower when we encounter real-world scenarios like fetching data from a server or reading a large file. This brings us to the concept of asynchronous JavaScript.

Asynchronous JavaScript

Asynchronous JavaScript is a "non-blocking" way to handle long-running tasks without blocking the main thread of execution. Let's see an example -

console.log("Task 1 completed");
setTimeout(() => {
  console.log("Task 2 completed");
}, 2000);
console.log("Task 3 completed");

/* Result :
    Task 1 completed
    Task 3 completed
    Task 2 completed
*/

Here, we are using setTimeout() function which delays the execution of a callback function using the event-loop. This enables our block with "Task 2" to be "shifted" out of our main thread of execution - keeping it in a queue stationed on the sideline. The main thread continues to execute as usual - and after a delay of at least 2 seconds (2000 ms), we get our message "Task 2 completed" in the console.

Now that we have become familiar with synchronous and asynchronous JavaScript, let's move into a nice and clean approach to handle asynchronous operations - Promise.

JavaScript Promises

A. Introduction to Promises

Promises are a way to handle asynchronous code in JavaScript, allowing developers to write cleaner, more readable code. A promise represents a value that may not be available yet but will be available at some point of time in the future. Promises have three possible states: "pending", "fulfilled", and "rejected".

B. Promise Constructor and Executor Function

To understand how promises work, let's consider an example of washing clothes in a washing machine. Imagine you have a pile of dirty clothes that you want to wash. You put them in the washing machine and start it, but you don't know exactly when the clothes will be clean and ready to be taken out - or whether the cleaning process would be completed as expected or not (maybe you forgot the detergent?).

This is like how promises work in JavaScript. When we create a new promise, we use the Promise constructor, which takes a function as its argument. This function is called the executor function, and it takes two parameters: resolve and reject. At the initial stage, the Promise is pending - which means it's still working on arriving at a definite outcome. The resolve function is used to signal that the promise has been fulfilled, while the reject function is used to signal that the promise has been rejected.

const washClothes = new Promise((resolve, reject) => {
  // Put clothes in washing machine and start it
  setTimeout(() => {
    const isClean = true;
    if (isClean) {
      resolve("Clothes are clean!");
    } else {
      reject("Error: Clothes are still dirty. You forgot the soap!");
    }
  }, 3000);
});

In this example, we're using a setTimeout function to simulate the washing machine taking some time to wash the clothes. The promise is resolved with a message saying that the clothes are clean if the clothes are successfully washed and are rejected with an error message if they are still dirty.

Consuming Promises

A. The then() and catch() Method

We can then use the then() and catch() methods to handle the results of the promise. The then() method is called if the promise is fulfilled, and it takes a function as its argument, which is called with the result of the promise. The catch() method is called if the promise is rejected, and it takes a function as its argument, which is called with the error message. Let's see our previous example of Promise again -

const washClothes = new Promise((resolve, reject) => {
  // Put clothes in washing machine and start it
  setTimeout(() => {
    const isClean = true;
    if (isClean) {
      resolve("Clothes are clean!");
    } else {
      reject("Error: Clothes are still dirty. You forgot the soap!");
    }
  }, 3000);
});
// Handling the Promise
washClothes
  // Handle Promise when the state is resolved
  .then(result => {
    console.log(result);
  })
  // Handle Promise when the state is resolved
  .catch(error => {
    console.log(error);
  });

Here, we're using the then() method to log the result of the promise if the state is "resolved" (i.e. - washing our clothes becomes successful), and the catch() method to log the error message if the promise is "rejected" (i.e. - our clothes are still dirty).

B. The finally() Method

The finally() method is used to act after a promise settles (i.e. - after we have received a decision on the state of the Promise), regardless of whether it is resolved or rejected. It takes a callback function as its argument which is called after the promise has settled. Let's refer to our previous example again -

washClothes
  // Handle Promise when the state is resolved
  .then(result => {
    console.log(result);
  })
  // Handle Promise when the state is resolved
  .catch(error => {
    console.log(error);
  })
  // Perform action after the Promise is settled
  .finally(() => {
    console.log("Operation completed. Start new task");
  });

The statement in finally() block is executed regardless of the outcome of the Promise. It just waits until the Promise is settled and gets executed after that.

C. Handling Multiple Promises

Sometimes we need to handle multiple promises at once. Imagine a scenario where you want to clean up the house. You have three tasks to do - wash the clothes, do the dishes and clean the trash. You don't really care about the sequence of the tasks and will be very happy if all of them are performed simultaneously on their own schedule.

To solve this, you can use Promise.all(). The Promise.all() method is used to handle an array of promises and returns a new promise that resolves when all the promises in the array have resolved. If any one of the promises in the array rejects, the returned promise will also reject. Let's see an example -

// First promise
const washClothes = new Promise((resolve, reject) => {
  let isClean = true;
  if(isClean){
    setTimeout(() => {
    resolve('Clothes are washed!');
    }, 2000);
  }
    else reject("Some error occurred");
});

// Second Promise
const doDishes = new Promise((resolve, reject) => {
  let isClean = true;
  if(isClean){
    setTimeout(() => {
    resolve('Dishes are done!');
    }, 2000);
  }
    else reject("Some error occurred");
});

// Third Promise
const clearTrash = new Promise((resolve, reject) => {
    let isClean = true;
  if(isClean){
    setTimeout(() => {
    resolve('Trashes cleared!');
    }, 2000);
  }
    else reject("Some error occurred");
});

// Handle multiple promises at the same time to get outcome
Promise.all([washClothes, doDishes, clearTrash]).then((results) => {
  console.log(results);
}).catch((error) => {
  console.log(error);
});

In this example, Promise.all() is used to wait for all Promises to be resolved before continuing with the code. Once all Promises are resolved, the then() method is called with an array of values returned by each Promise.

Best Practices

A. Error Handling in Promises

One of the key benefits of promises is the ability to handle errors more cleanly than with traditional callbacks. When a promise is rejected, you can use the catch() method to handle the error in a more centralized way. It's important to always include a catch() block to handle any rejected promises and avoid unexpected errors. For example -

myPromise
  // Handle Promise when the state is resolved
  .then(result => {
    console.log(result);
  })
  // Handle Promise when the state is resolved
  .catch(error => {
    console.log(error);
  });

B. Debugging and Testing Promises

When working with promises, it's important to be able to debug and test them effectively. One useful tool is the Promise.resolve() method, which creates a new resolved promise with the specified value. This can be useful for testing promise chains or simulating successful promises in tests. Additionally, you can use console.log() statements or a debugger to inspect the state of promises and see how they're being resolved or rejected.

C. Avoiding "callback hell"

One of the main reasons for the rise in popularity of promises is to avoid the problem of "callback hell", where code becomes difficult to read and maintain due to deeply nested callbacks. Promises provide a cleaner and more organized way to handle asynchronous code, but it's important to avoid unnecessary nesting and use the chaining mechanism provided by the then() method instead.

Review

Let's review the key points that we have discussed so far -

  • JavaScript can be synchronous or asynchronous.

  • Synchronous JavaScript blocks the execution of code until a function call is completed, which can cause the program to hang or freeze.

  • Asynchronous JavaScript allows code to continue executing while waiting for a function call to complete, which makes the program more responsive and efficient.

  • Promises are a way to handle asynchronous code in a more readable and manageable way.

  • Promises can be created using the Promise constructor and the executor function. Promises can be resolved or rejected using the resolve and reject functions.

  • Promises can be consumed using the then(), catch(), and finally() methods. Multiple promises can be handled using Promise.all() .

  • Best practices for working with promises include proper error handling, avoiding callback hell, and debugging and testing techniques

Conclusion

Pat yourself on the back! You’ve learned a lot about asynchronous JavaScript and Promises. If you're just starting out with JavaScript, learning about promises can be a game-changer. While the concept may seem daunting at first, it's an essential tool for any developer working with asynchronous code. But you know, with practice - you can master all these concepts which will then help you write more readable and efficient code, avoid common pitfalls, and make your programs more robust and scalable. So, let's code!