The 'Promise' Bible of Javascript

The 'Promise' Bible of Javascript

The Ultimate Guide to Promises in Javascript

·

12 min read

Introduction

JavaScript is the land of curly braces, semicolons, and the occasional "TypeError: undefined is not a function." It's a language that's both loved and feared by developers around the world.

In one of our previous blogs, we discussed world domination using Objects in Javascript. If you've not read the article, you can read it here (quick promotion xD).

This time, we're about to embark on an epic journey through the realm of Promises in JavaScript!

Motivation

The Drama of Asynchronous Code

Well, to be honest, there is nothing new as such that can be done with Promises and was not possible earlier. But...... (ah sh*t, there's always a catch!)

Picture this: you're crafting a web application, and it's time to fetch data from the mystical realm of servers. But here's the twist—JavaScript is single-threaded. That means when it's busy doing one thing, it can't simultaneously tackle another task.

So, when you send your trusty code on a quest to fetch data, it waits impatiently for the data to arrive, twiddling its digital thumbs while the rest of your application freezes like a statue.

In the world of coding, this is what we call "Callback Hell" or the dreaded "Pyramid of Doom." Consider the following code for a boring yet important example:-

const startCallbackHell = () => {
  fetch('https://api.example.com/endpoint1')
    .then(response1 => {
      // First request completed
      return response1.json();
    })
    .then(data1 => {
      // Do something with data from the first request
      fetch('https://api.example.com/endpoint2')
        .then(response2 => {
          // Second request completed
          return response2.json();
        })
        .then(data2 => {
          // Do something with data from the second request
          fetch('https://api.example.com/endpoint3')
            .then(response3 => {
              // Third request completed
              return response3.json();
            })
            .then(data3 => {
              // Do something with data from the third request
            })
            .catch(error => {
              // Handle errors for the third request
            });
        })
        .catch(error => {
          // Handle errors for the second request
        });
    })
    .catch(error => {
      // Handle errors for the first request
    });
}

// main function
startCallbackHell();

To overcome this drawback, callbacks were used predominantly. However, we're developers, we make everything overly complicated and screw many things up.

Enter Promises: Our Valiant Heroes

Now, imagine a hero stepping onto the scene — Promises!

These Promises aren't the kind you make to yourself about going to the gym (we all know how those turn out). Instead, these are JavaScript's secret agents, ready to tackle asynchronous tasks with finesse.

They promise to fetch data, defeat network errors, and free your code from the shackles of chaos and darkness. With Promises, your code can march forward, fetch data, and conquer asynchronous challenges without freezing up or getting lost in the labyrinth of callbacks.

In this quest, we shall explore the power of Promises, learn how to wield their might and emerge as victorious JavaScript developers, unburdened by the perils of Callback Hell. So, saddle up, my fellow adventurers, as we dive into the world of Promises in JavaScript! 🚀

Basics of a Promise

The Definition

According to MDN docs, a Promise is a proxy for a value not necessarily known when the promise is created.

In easier words, a promise is an object that may produce some data in the future. This data could either a be resolved value, or a reason why the promise was not resolved. (for example, a network error).

Advantages of Promises

  • Leaner Code: Promises give us the ability to write cleaner code but reduce (or entirely remove) call-back hell.

  • Sequential Asynchronous Operations: Promises straightforwardly simplify chain asynchronous actions using the .then() method.

  • Error Handling: Promises offer a standardized and clean way to handle errors through the .catch() method.

  • Parallel Execution: Promises make it easier to manage the parallel execution of multiple asynchronous tasks using methods such as Promise.all() and Promise.race().

  • Maintainability: Promises improve code maintainability by offering a more linear and readable flow of asynchronous logic.

  • Reusability: Promises make it easier to write reusable asynchronous functions and modules

  • Error Propagation: Promises allow errors to propagate through the promise chain, simplifying error handling and logging.

The States of a Promise

So, in essence, a promise is an object which can be returned synchronously from an asynchronous function. It will be in one of 3 possible states:

  • Fulfilled: resolve() will be called (the promise is successful)

  • Rejected: reject() will be called (the promise failed)

  • Pending: not yet fulfilled or rejected (it is yet to be resolved or rejected)

Note: A promise is settled if it’s not pending (it has been resolved or rejected). However, resolved and settled do not mean the same! Once a promise is settled, a promise can not be resettled. Calling resolve() or reject() again will have no effect.

In JS, promises are treated as a black box. Only the function responsible for creating the promise will know the promise status, or access to resolve or reject.

Creating a Promise

To create a promise, you need to create an instance object using the Promise constructor function. The Promise constructor function takes in one parameter, that is, a function defining when to resolve the promise, and a second optional parameter defining when to reject it.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve("Done!"), 1500);
});

Note: This kind of pattern of creating a promise is called a revealing constructor because the single function argument reveals its capabilities to the constructor function, but ensures that consumers of the promise cannot manipulate its state.

In promises, resolve is a function with an optional parameter representing the resolved value. Also, reject is a function with an optional parameter representing the reason why the promise failed. In the example above, the resolved value of the promise is the string 'Done!'.

Attaching a callback to a Promise

To create a callback for a promise, you need to use the .then() method. This method takes in two callback functions.

The first function runs if the promise is resolved, while the second function runs if the promise is rejected.

const promise = new Promise((resolve, reject) => {
  const num = Math.random();
  if (num >= 0.5) {
    resolve("Promise fulfilled");
  } else {
    reject("Promise failed");
  }
});

function handleResolve(value) {
  console.log(value);
}

function handleReject(reason) {
  console.error(reason);
}

promise.then(handleResolve, handleReject);

Here is yet another version of the same code with some good practices:-

const promise = new Promise((resolve, reject) => {
  const num = Math.random();
  if (num >= 0.5) {
    resolve("Promise fulfilled");
  } else {
    reject("Promise failed");
  }
});

// using arrow function
promise.then((response) => {
    console.log(response);
}).catch((error) => {
    console.error(error);
});

Promises make it incredibly easy to chain asynchronous instructions. When you handle a promise with the .then() method, the operation always returns another promise. By employing this approach, you can eliminate the previously mentioned 'Callback Pyramid of Doom'.

Rules of a Promise

A standard for promises was defined by the Promises/A+ specification community. There are many implementations that conform to the standard, including the JavaScript standard ECMAScript promises.

Promises following the spec must follow a specific set of rules:

  • A promise or “thenable” is an object that supplies a standard-compliant .then() method.

  • A pending promise may transition into a fulfilled or rejected state.

  • A fulfilled or rejected promise is settled, and must not transition into any other state.

  • Once a promise is settled, it must have a value (which may be undefined). That value must not change.

Every promise must supply a .then() method with the following signature:

promise.then(
  onFulfilled?: Function,
  onRejected?: Function
) => Promise

The .then() method must comply with these rules:

  • Both onFulfilled() and onRejected() are optional.

  • If the arguments supplied are not functions, they must be ignored.

  • onFulfilled() will be called after the promise is fulfilled, with the promise’s value as the first argument.

  • onRejected() will be called after the promise is rejected, with the reason for rejection as the first argument. The reason may be any valid JavaScript value, but because rejections are essentially synonymous with exceptions, I recommend using Error objects.

  • Neither onFulfilled() nor onRejected() may be called more than once.

  • .then() may be called many times on the same promise. In other words, a promise can be used to aggregate callbacks.

  • .then() must return a new promise, promise2.

  • If onFulfilled() or onRejected() return a value x, and x is a promise, promise2 will lock in with (assume the same state and value as) x. Otherwise, promise2 will be fulfilled with the value of x.

  • If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.

  • If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1.

  • If onRejected is not a function and promise1 is rejected, promise2 must be rejected for the same reason as promise1.

Promise Chaining

Because .then() always returns a new promise, it’s possible to chain promises with precise control over how and where errors are handled. Promises allow you to mimic normal synchronous code’s try/catch behaviour.

function process(args){
    // function implementation below
}

function save(args){
    // function implementation below
}

function process(handleErrors){
    // function implementation below
}

fetch(url)
  .then(process)
  .then(save)
  .catch(handleErrors)
;

Assuming each of the functions, fetch(), process(), and save() return promises, process() will wait for fetch() to complete before starting, and save() will wait for process() to complete before starting. handleErrors() will only run if any of the previous promises are rejected.

Consider the below-given example for a real-world scenario of promise chaining:-

function fetchUserData(userId) {
  return fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
    .then(response => response.json());
}

function fetchUserPosts(userId) {
  return fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
    .then(response => response.json());
}

function displayUserPosts(posts) {
  console.log(`User's Posts:`);
  posts.forEach((post, i) => {
    console.log(`${i}. - ${post.title}`);
  });
}

// Usage
fetchUserData(1)
  .then((user) => {
    console.log(`User: ${user.name}`);
    return fetchUserPosts(user.id);
  })
  .then((posts) => {
    return displayUserPosts(posts);
  })
  .catch((error) => {
    console.error("An error occurred:", error);
  });

Error Handling

Note that promises have both a success and an error handler and it’s very common to see code that does this:

save().then(
  handleSuccess,
  handleError
);

But what happens if handleSuccess() throws an error? The promise returned from .then() will be rejected, but there’s nothing there to catch the rejection — meaning that an error in your app gets swallowed. Oops!

For that reason, some people consider the code above to be an anti-pattern, and recommend the following, instead:

save()
  .then(handleSuccess)
  .catch(handleError)
;

Without .catch(), an error in the success handler is uncaught.

Without .catch(), an error in the success handler is uncaught.

With .catch(), both error sources are handled.

To chain an asynchronous operation to a promise regardless of whether the promise is resolved or not, use the .finally() method. The .then() method is how you handle the results of a promise writing individual conditions for both resolved and rejected. .catch() runs only when there is an error. But sometimes you might want an operation to run no matter what happens to earlier promises.

Using finally() helps prevent possible code repetition in .then() and .catch(). It is for operations you must run whether there is an error or not.

fetchResource(url)
  .then(handleResult)
  .then(handleNewResult)
  .finally(onFinallyHandle);

The finally() method has a few use cases in real-world applications. It is important if you want to perform cleanup operations for activities the promise initiated. Another use case—on Front-End Web Applications—is making user interface updates like stopping a loading spinner.

Extras of the Native JS Promise

The native Promise object has some extra stuff you might be interested in:

  • Promise.reject() returns a rejected promise.

  • Promise.resolve() returns a resolved promise.

  • Promise.race() takes an array (or any iterable) and returns a promise that resolves with the value of the first resolved promise in the iterable or rejects with the reason of the first promise that rejects.

  • Promise.all() takes an array (or any iterable) and returns a promise that resolves when all of the promises in the iterable argument have resolved or rejects with the reason of the first passed promise that rejects.

In addition, callbacks are the backbone of some new syntax features coming in ES2017, such as async functions, which allow an even cleaner way of writing code.

The third thing that promises do is not immediately apparent when you first learn the syntax -- automatic error handling. Promises allow errors to be passed down the chain and handled in one common place without having to put in layers of manual error handling.

Async-Await

Yes, it is true that the Promises have already been our mystical heroes and revolutionized asynchronous programming in JavaScript.

However, the introduction of async and await takes things to a whole new level of clarity and elegance. Think of them as the Gandalf and Aragorn of JavaScript, guiding your code through the treacherous paths of asynchronicity with wisdom and valour.

The Old Way: Chaining Promises

In the Promises world, you might have encountered code like this:

function fetchSomeData() {
  return fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => {
      // Handle data here
    })
    .catch(error => {
      // Handle errors here
    });
}

This code is functional, but it resembles a chain of callbacks, and it's not always easy to follow the flow of asynchronous operations. This is where async and await come to the rescue.

The New Way: Async/Await

With async and await, the same code can be refactored into a more synchronous-looking structure:

async function fetchSomeData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    // Handle data here
  } catch (error) {
    // Handle errors here
  }
}

Here's what's happening:

  1. We declare the function as async, indicating that it will contain asynchronous operations.

  2. We use the await keyword before the fetch and response.json() calls. This tells JavaScript to pause the function's execution until the Promise resolves.

  3. We catch errors using a try...catch block, making error handling cleaner and more straightforward.

The Magic of await

The real magic of await is that it allows you to write asynchronous code that looks almost synchronous. It feels like you're waiting for each step to complete before moving on to the next, just as you would with synchronous code.

async function doTasks() {
  const result1 = await task1();
  const result2 = await task2();
  const result3 = await task3();
  // Proceed with results
}

This makes your code more readable, maintainable, and less prone to the infamous "Callback Hell."

Compatibility Note

The await keyword can only be used inside an async function in old browsers. We may get a syntax error if we try to use await inside normal functions. However, most modern browsers and recent versions of Node.JS support Top-Level Await.

Conclusion

In this journey, we passed through the fascinating realm of Promises in Javascript and unlocked the mystical powers of asynchronous coding in Javascript.

Now you're equipped with the secret knowledge of writing asynchronous code that's not only efficient but also readable and elegant.

You know how to simplify error handling and make your codebase a joy to navigate, ensuring that you, the valiant JavaScript developer, can conquer asynchronous challenges with grace and confidence.

Happy Coding!

Did you find this article valuable?

Support Utkarsh by becoming a sponsor. Any amount is appreciated!