JavaScript Promises Mastery: The Ultimate Guide to Asynchronous Programming Using Promises

JavaScript Promises Mastery: The Ultimate Guide to Asynchronous Programming Using Promises

Navigate the World of Promises with Easy-to-Follow, In-Depth Beginner Friendly Instruction

Introduction

Asynchronous programming

During the coding process, we often encounter situations where certain actions, such as complex calculations or fetching data from external sources like an API, may take a relatively longer time to complete. Waiting for these operations to finish can cause the software to become unresponsive, resulting in a sluggish user experience.

Asynchronous programming comes into play as a solution to this problem. It allows tasks to be executed in the background, freeing up the main thread to continue running other operations. This means that while the complex calculation or data fetching is underway, the software can remain responsive and continue to perform other tasks.

Once the asynchronous operation is completed, it returns the requested data if everything went smoothly, or an error if something went wrong during the process. This approach helps ensure that the software remains responsive and provides a smoother and more efficient user experience.

In JavaScript, asynchronous programming can be approached in various ways, including: Callbacks, Promises - Async/Await, and Event Emitters.

In this article, we will primarily focus on Promises, a powerful tool in JavaScript for handling asynchronous operations. We'll delve into their usage, and provide practical guidance on how to work with them effectively.

Definition

So what is promises?

Promise is built-in objects (or global objects) in JavaScript introduced in ES6. It contains constructor that allow us to create instances of the Promise object.

An instance of a Promise object can exist in one of three states: "pending," "fulfilled," or "rejected". Simply, the instance create will contain a property named "state" which holds one of these values: "pending" while the computer is carrying out a task and not finished yet, "fulfilled" when the task is completed successfully, and "rejected" if the task encounters an error.

Example of creating a promise:

// Create a new Promise
const myPromise = new Promise(function(resolve, reject) {
    try {
        // Carry out the asynchronous task...

        // If everything is successful, call the resolve function
        if (/* condition */) {
            let result = {}; // result of the task
            resolve(result); // pass data as a parameter if necessary
        } else {
            // If an error occurs, handle it and call the reject function
            throw new Error("An error occurred.");
        }
    } catch (error) {
        reject(error); // Pass the error to the reject function
    }
});

In the above code, we invoke the Promise() constructor and assign its value (the promise instance) to the myPromise variable. This constructor requires a function, often referred to as an executor. The executor will receive two parameters during runtime: resolve and reject. Within the executor function body, it's up to you to define the logic that specifies when the operation succeeds or fails. We call the resolve() function and pass values to it when everything goes as it should. Otherwise, we call reject() and pass a string (or an object e.g. {message:"no data"}) representing the reason behind the failure of the operation.

Then, Catch and Finally

The true potential of promises becomes evident when we utilize the "then" and "catch" methods, both of which accept callback functions.

//myPromise already defined

myPromise.then(function(result) {
  // Code to execute when the promise is resolved
}).catch(function(error) {
  // Code to handle errors
});

Then()

The callback function passed to the then method will be executed if the promise is successfully resolved, which occurs when the resolve function passed to the constructor is invoked. This callback function may receive the resolved value as an argument if data is passed through the resolve function during the resolution of the promise.

The data passed to resolve function within the executor e.g.

//within the executor function passed to the Promise constructor
//...
let dataFromDb = {};
 reslove(dataFromDb);
//...

will be passed to the callback attached to then as parameter

//...
myPromise.then( function(dataFromDb){ 
 //...
})
//...

Catch()

On the other, the callback function passed to the catch method will be executed if the promise is rejected, i.e., the reject function given as second parameter to the executor function passed to constructor is invoked.

This callback function typically receives the error or rejection reason as an argument, allowing us to handle the error or failure gracefully.

//within the executor function passed to Promise constructor
let reasonToFail = {message:"No such User"};
reject(reasonToFail)

//...

myPromise.then(/**/)
.catch(function(reasonToFail){
//...
})

We could eliminate the catch block and replace it by a callback function passed as second parameter passed to then

//...

function resolveFunction(result) {
  // Code to execute when the promise is resolved
}


function rejectionFunction(error) {
  // Code to handle errors
}

myPromise.then(resolveFunction,rejectionFunction)

Even it is possible it is not common so it is better to stick to defining the catch block

💡
If no catch block is present, and the error reaches the global error handler (such as the window.onerror handler in a browser environment or the process.on('uncaughtException) handler in Node.js), the error will typically result in an unhandled promise rejection, and the JavaScript runtime environment may log the error to the console or take other actions depending on the environment and configuration potentially leading to the termination of the application

finally()

The finally method in promises provides a way to execute code regardless of whether the promise is resolved or rejected, in other words it will run in all cases. It is commonly used for cleanup tasks such as closing connections, releasing resources, or performing other cleanup operations that should be done regardless of the outcome of the promise.

fetchData()
  .then(data => {
    // Process the data
    console.log("Data:", data);
  })
  .catch(error => {
    // Handle errors
    console.error("Error:", error);
  })
  .finally(() => { //runs in all cases
    // Cleanup tasks
    console.log("Cleanup: Closing connection...");
  });

Flattening and Chaining

It is common to find ourselves in situations where we need to perform additional asynchronous operations once the first action succeeds. This is typically done within the callback function provided to the then method of a Promise. A very common example of this scenario is decoding JSON data received from an API call.

It is tempting to invoke the then method on the newly introduced Promise.

So, the code would look like this:

myPromise.then(function(result) {
     doAnotherAsyncOperation()
                .then(function(newResult){})//then within the then 
                .catch(function(err){})
    })
    .catch(function(err){
        //...
    })

Although it would work, it is better to simplify the code by flattening the nested then methods and chaining them to end up with a single level.

To chain multiple then we should first return the promise returned by doAnotherAsyncOperation, after that chain the new then block to the already existing one. This approach avoids nested then methods and achieves a single level of chaining, enhancing readability and maintainability.

myPromise
    .then(function(result) {
        //Don't forget the return!
        return doAnotherAsyncOperation();    
    })
    .then(function(result) {
        // Handle success of doAnotherAsyncOperation
    })
    .catch(function(err) {
        // Handle errors that may have occurred in any of the previous 'then' methods
    });
💡
Be aware that if you forget to return the promise from doAnotherAsyncOperation, the result passed to the next then will be undefined, and weird bugs will start to appear. This issue is commonly referred to as a 'floating promise'.

Useful Method

Promise built-in object provide a set a methods that are useful when we want to work with an array of promises.

all()

This method takes an array of promises as input and returns a single Promise that resolves when all of the promises with the array have resolved, or rejects when any of the promises is rejected. This is useful when you have multiple asynchronous tasks that can be executed concurrently and you want to wait for all of them to complete before continuing.

Here's an example that you will come across

// Array of users
const users = [
    { id: 1, name: "User1" },
    { id: 2, name: "User2" },
    { id: 3, name: "User3" }
];

// Using map to process each user asynchronously
const processedUsersPostsPromises = users.map(user => {
    // fetchUserPosts will fetch data from db and will return a promise
    return fetchUserPosts(user.id);
});

// Wait for all promises to resolve using Promise.all
Promise.all(processedUsersPostsPromises)
    .then(userPosts => {
        // All user posts have been processed
        console.log('Processed user posts:', userPosts);
    })
    .catch(error => {
        // Handle errors if any of the promises reject
        console.error('Error processing user posts:', error);
    });

any()

The Promise.any() method also works with an array of promises, resolving as soon as any of the promises in the array resolves. It executes the callback provided to the then method when any of the promises is resolved, without waiting for the others


const promise1 = new Promise(resolve => setTimeout(resolve, 2000, "Promise 1 resolved"));
const promise2 = new Promise(resolve => setTimeout(resolve, 1000, "Promise 2 resolved"));
const promise3 = new Promise((resolve, reject) => setTimeout(reject, 3000, "Promise 3 rejected"));

Promise.any([promise1, promise2, promise3])
  .then(result => {
    console.log("At least one promise fulfilled:", result); 
    // Resolves with "Promise 2 resolved"
  })
  .catch(error => {
    console.error("All promises rejected:", error); // Not executed
  });

race()

The Promise.race() method returns a promise that settles (resolves or rejects) as soon as one of the promises in the array settles. If the first settling promise fulfills (resolves), the resulting promise resolves with the fulfillment value of that promise. If the first settling promise rejects, the resulting promise rejects with the rejection reason of that promise

Example 1: First one will be settled will be promise2 and its state is resolved.

const promise1 = new Promise(resolve => setTimeout(resolve, 2000, "Promise 1 resolved"));
const promise2 = new Promise(resolve => setTimeout(resolve, 1000, "Promise 2 resolved"));
const promise3 = new Promise((resolve, reject) => setTimeout(reject, 3000, "Promise 3 rejected"));

Promise.race([promise1, promise2, promise3])
  .then(result => {
    console.log("First promise settled:", result); 
    // Resolves with "Promise 2 resolved"
  })
  .catch(error => {
    console.error("First promise rejected:", error); // Not executed
  });

Example 2: First one will be settled will be promise3 and its state is rejected.

const promise1 = new Promise(resolve => setTimeout(resolve, 2000, "Promise 1 resolved"));
const promise2 = new Promise(resolve => setTimeout(resolve, 4000, "Promise 2 resolved"));
const promise3 = new Promise((resolve, reject) => setTimeout(reject, 1000, c));

Promise.race([promise1, promise2, promise3])
  .then(result => {
    console.log("First promise settled:", result); 
    // Not executed
  })
  .catch(error => {
    console.error("First promise rejected:", error); 
    // First promise rejected: Promise 3 rejected
  });

allSettled()

The Promise.allSettled() method waits for all the given promises in the array to finish, whether they succeed or fail. It doesn't stop when one promise fails, and it doesn't combine success and failure into one result.

const promise1 = new Promise(resolve => setTimeout(resolve, 2000, "Promise 1 resolved"));
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 1000, "Promise 2 rejected"));
const promise3 = new Promise(resolve => setTimeout(resolve, 3000, "Promise 3 resolved"));

Promise.allSettled([promise1, promise2, promise3])
  .then(results => {
    console.log("All promises settled:", results);
    /* results:
        [
          { status: "fulfilled", value: "Promise 1 resolved" },
          { status: "rejected", reason: "Promise 2 rejected" },
          { status: "fulfilled", value: "Promise 3 resolved" }
        ]
    */
  });
allanyallSettledrace
All promises should within the array should be resolved in order to be resolved. If single one failed Catch will be invokedIf at least single promise resolved the then block will be invokedIt wait for all promises to be settled (rejected/resolved) and the then block will be invoked in all casesIts state depends on the result of the first/fastest promises settled. If it has been resolved then will be invoked, it has been rejected catch will be invoked.

Async/Await to Write Synchronous Like Code

async and await are two keywords used to work with promises with ease. By placing await keyword in front of the promise we are telling js to wait till the promise it resolved and its value will be assigned to a variable.

//...
const result = await doAsyncOperation() //We no more need the then block
//...

In order for the await to work properly it should be used within a function marked as async.

async function main(){
 const result = await doAsyncOperation() 
}

Try/Catch

In the previous section we learned that we can replace then with the await keyword, but what about handling errors in case the operation gets rejected?

It's pretty simple: we put the code in a try block, and within the catch block, we handle the operation's failure.

async function main(){
    try{
      const result = await doAsyncOperation() 
    }catch(error){
     //handel the error
    }
}

Real-world Examples with JavaScript Promises

We rarely write promises ourselves; instead, we often interact with libraries and APIs that utilize Promises. Below, we will mention a few of them:

Fetch API

A native JavaScript API for making asynchronous HTTP requests in the browser, which also returns Promises.

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Axios

A promise-based HTTP client for the browser and Node.js, which provides an easy-to-use interface for making HTTP requests.

axios.get('https://api.example.com/data')
  .then(response => {
    console.log(response.data);
  })
  .catch(error => {
    console.error(error);
  });

Reading a File using Node.js fs.promises

const fs = require('fs').promises;

fs.readFile('file.txt', 'utf8')
  .then(data => {
    console.log('File contents:', data);
  })
  .catch(error => {
    console.error('Error reading file:', error);
  });

Firebase

Google's mobile and web application development platform provides a JavaScript SDK that returns Promises for interacting with its services, such as Firebase Authentication, Firestore, and Realtime Database.

firebase.firestore().collection('users').doc('userID').get()
  .then(doc => {
    if (doc.exists) {
      console.log(doc.data());
    } else {
      console.log('No such document!');
    }
  })
  .catch(error => {
    console.error('Error getting document:', error);
  });

Conclusion

So, wrapping it all up, we've taken a deep dive into the world of asynchronous programming in JavaScript, focusing mainly on Promises and how they make handling those tricky async tasks a breeze. With methods like then, catch, and finally, along with cool tricks like Promise.all and async/await, we've learned how to juggle multiple async operations like a pro.

By mastering these techniques, you'll not only make your code more efficient but also ensure it's robust and reliable, even when things don't go as planned. So go ahead, try out these methods in your projects, and let's make JavaScript programming a whole lot smoother and friendlier together!