Asynchronous JavaScript: Refactoring Callbacks to Promises in Node.js

October 14, 2019
Written by
Maciej Treder
Contributor
Opinions expressed by Twilio contributors are their own

Advanced JavaScript: Refactoring Callbacks to Promises

If you know how the event loop mechanism works in JavaScript you know how it enables you to work with asynchronous events. You might also know how to refactor your code into separate functions to reduce the amount of nesting associated with a sequence of callback functions. If you work with web APIs you are probably familiar with the JavaScript request library, which is used to perform HTTP calls.

You can put these skills together to retrieve remote data through an HTTP request and work with the call response asynchronously. But in doing so you’ll probably notice that code has at least one pitfall: the logical order of declared functions is reversed. That makes the code hard to read and maintain.

With the node-fetch JavaScript library and Promise objects you can reduce nesting, known as the callback “Pyramid of Doom”, and organize your code in a more readable and logical order.

While this post focuses on how to improve the modularity and flow of your code with these tools, the previous posts in the series explain Promise objects in more detail:

        Asynchronous JavaScript: Introduction to JavaScript Promises

        Asynchronous JavaScript: Advanced Promises with Node.js

If you need to brush up on callbacks you can learn about them in the first post in this series, Asynchronous JavaScript: Understanding Callbacks.

Prerequisites

To accomplish the tasks in this post you will need the following:

You should also have a working knowledge of the core elements of JavaScript, including object constructors and anonymous functions. Read the first post in this series if you are not familiar with the JavaScript event model.

There is a companion repository for this post, available on GitHub.

Setting up the project

If you have completed the project from the Asynchronous JavaScript: Advanced Promises post you can continue with the code you wrote for that post. If you are familiar with the event loop mechanism, or want to start fresh, you can get the code from GitHub.

Clone the project by executing the following command-line instructions in the directory where you would like to create the project root directory:

git clone https://github.com/maciejtreder/asynchronous-javascript.git
cd asynchronous-javascript
git checkout step8
npm install
cd promises

Understanding the case study project

The case study project for this post determines the best movie by Quentin Tarantino based on review scores. To accomplish this, the code will retrieve data from REST API mockups on GitHub.io. It achieves the same goal with Promise objects as the case study in the Asynchronous Javascript: Organize Callbacks for Readability and Reusability post did with callbacks.

You can simulate the process manually by performing GET calls with your browser:

  1. Navigate to https://maciejtreder.github.io/asynchronous-javascript/directors/. You should see the list of JSON objects representing directors. Quentin Tarantino has id 2.
  2. Navigate to https://maciejtreder.github.io/asynchronous-javascript/directors/2/movies/ to see the list of Tarantino films.
  3. See the reviews for Inglourious Basterds by navigating to https://maciejtreder.github.io/asynchronous-javascript/movies/5/reviews.
  4. Add up all the scores and divide by the number of scores to get the average review score.

In addition to learning how to code a project like this, you’ll see a number of advantages to using promises for retrieving and manipulating API data:

  • promises can be chained together
  • promises can include error rejection events
  • errors in a chain of promises need only be handled once

Retrieving REST API data using node-fetch and promises

Armed with knowledge about promises, you can write a more readable program to retrieve the API data and determine the movie with the highest review score. To develop a deeper understanding of this refactoring compare the code in this project with the equivalent code in the organizedCallbacks.js file from the companion repository.

Start refactoring from the first REST API call in asynchronous-javascript/asyncCallback.js. The first step is to use a Promise object instead of a callback to get a list of directors from the API.

In the asynchronous-javascript/promises directory, create a new file named fetch.js and insert the following JavaScript code into it:

const fetch = require('node-fetch');

fetch(`https://maciejtreder.github.io/asynchronous-javascript/directors/`)
.then(response => response.json())
.then(console.log);

Run the code:

node fetch.js

In the output you should see list of directors in JSON format:

{ id: 1

Yay. That works. The output is the same as from the callback in asyncCallback.js.

As you probably know by now, you can chain multiple promises together and return the manipulated output. You can do that to perform multiple API calls in a series of callback functions using the Promise object prototype .then method.

Replace the code in the fetch.js file with following:

const fetch = require('node-fetch');

fetch(`https://maciejtreder.github.io/asynchronous-javascript/directors/`)
.then(response => response.json())
.then(directors => {
  let tarantinoId = directors.find(director => director.name === "Quentin Tarantino").id;
  return fetch(`https://maciejtreder.github.io/asynchronous-javascript/directors/${tarantinoId}/movies`);
})
.then(response => response.json())
.then(console.log);

Run the code. You should see a JSON format list of movies directed by Quentin Tarantino.

What you are doing here is manipulating the response from the first API call: you are looking for the Quentin Tarantino id value and using it with the next fetch call, then passing the result forward to another promise:

return fetch(`https://maciejtreder.github.io/asynchronous-javascript/directors/${tarantinoId}/movies`);

With response.json() you are retrieving the JSON body from the API response, which is a list of movies ready to pass to the next promise in the chain. The last line sends the list of movies to the console window.

Now that you have a list of Quentin Tarantino’s movies you can make multiple API calls to retrieve the score from each of the reviews. Then you can collect them into an array of Promise objects and use the Promise.all static method to return a single promise containing all the reviews.

Insert the following code into the fetch.js file immediately before the .then(console.log) line:

.then(movies => {
   let reviewsArr = [];
   movies.forEach(movie => {
       reviewsArr.push(
           fetch(`https://maciejtreder.github.io/asynchronous-javascript/movies/${movie.id}/reviews`)
           .then(response => response.json()).then(reviews => {
               return {movie: movie, reviews: reviews};
           })
       );
   });
   return Promise.all(reviewsArr);
})

In the console window output you should see a list of nested objects containing the reviews for each movie:

{ movie: { id: 4

Now that you have all the review responses in hand, you can calculate the average score and determine which movie is the best one.

Replace the last line of fetchMultiple.js (the .then(console.log) statement) with the following code:

.then(reviewSets => {
   let moviesAndRatings = [];
   reviewSets.forEach(reviews => {
       let aggregatedScore = 0;
       reviews.reviews.forEach( review => aggregatedScore += review.rating );
       let averageScore = aggregatedScore / reviews.reviews.length;

       moviesAndRatings.push({title: reviews.movie, averageScore: averageScore});
   });
   return moviesAndRatings.sort((m1, m2) => m2.averageScore - m1.averageScore)[0].title;
})
.then(movie => console.log(`The best movie by Quentin Tarantino is... ${movie.title}!`));

Run the fetch.js code. You should see output indicating which movie the reviews favor:

The best movie by Quentin Tarantino is... Inglourious Basterds!

The code in fetch.js is now functionally equivalent to the code in both nestedCallback.js and organizedCallbacks.js. It’s more maintainable and easier to debug than the nested callback structure and it’s more readable than the organizedCallbacks.js because the code is organized from top to bottom in the order of execution.

Your application workflow is described by the following diagram. Every request to an API returns a Promise object, which incorporates a call to another request. When the request is completed and the promise resolved (or rejected), the event loop pushes the next promise to the execution stack.

Diagram of program flow in a chain of JavaScript promises used to retrieve data from multiple APIs

 

Adding an interactive element to the command line output

The chain of asynchronous promise operations you’ve created thus far work great for retrieving data, but sometimes APIs or the internet can be slow to respond. It would be helpful to let the user know your JavaScript program is working while they’re waiting.

Fortunately, there’s a nice npm package to do that for command-line applications, the ora library. Install it with following command:

npm i ora

At the very beginning of the file initialize the loader:

const ora = require('ora');
const spinner = ora('The best movie by Quentin Tarantino is...').start();

And replace last then with the following lines, which will cause loader to stop and display the results:

.then(movie => spinner.succeed(` ${movie.title} !`))
.catch(error => spinner.fail(error));

Handling errors in promises using the .catch method

In addition to enhancing the command line experience for your application’s users, your latest modifications also added error handling by including a .catch method call. If any of the promises are rejected, either because of an invalid API response or because of an execution error, the .catch method will handle the error and call the ora spinner.fail method. The default behavior for this method is to display a red “x”.

You  can easily try this by replacing the tarantinoId or movie.id values in the API calls with “fail”, or some other invalid argument. You’ll see that errors that occur early in the chain of promises are handled by the .catch method, even though it’s the last method call in the chain.

If you haven’t been coding along and want to catch up to this step, execute the following commands to clone the project from the companion repository:

git clone https://github.com/maciejtreder/asynchronous-javascript.git
cd asynchronous-javascript
git checkout step9
npm install
cd promises

Summary

You use JavaScript Promise objects to refactor JavaScript code written with basic callbacks. Promises enable you to implement asynchronous functionality in a way that’s more robust, reusable, maintainable, and easier to read. This post demonstrates how to refactor an existing program which makes multiple API calls with traditional callbacks into a structure based on promise chaining using the Promise object prototype .then method. The project also includes error handling with the Promise object prototype .catch method and it demonstrates how to incorporate external modules into the asynchronous functions called in the promise chain.

Additional resources

Complex async code made easier – developer.google.com presents a similar case study and provides a more extensive explanation of error handling and additional advanced topics.

Creating a Promise around an old callback API – developer.mozilla.org provides instructions for wrapping an API that doesn’t return a Promise object with a Promise so it can be used in places, like a chain of promises, where a Promise object would be required. It’s worth noting the the ubiquitous setTimeout function is an example.

Maciej Treder is a Senior Software Development Engineer at Akamai Technologies. He is also an international conference speaker and the author of @ng-toolkit, an open-source toolkit for building Angular progressive web apps (PWAs), serverless apps, and Angular Universal apps. Check out the repo to learn more about the toolkit, contribute, and support the project. You can learn more about the author at https://www.maciejtreder.com. You can also contact him at: contact@maciejtreder.com or @maciejtreder on GitHub, Twitter, StackOverflow, and LinkedIn.