Asynchronous JavaScript: Organizing Callbacks for Readability and Reusability

June 28, 2019
Written by
Maciej Treder
Contributor
Opinions expressed by Twilio contributors are their own

cxgwWEzJkPC0ZPM2fWcHu3BrGZXaMUO2Z0tUwOu9UTctbND9UrJpqwisJe7EOp9UZlf6sUZhhnH80lvOQadVfLyAfaswgFqm0BB9rs6FD9beeFkQYLMHSwNBo1kNwgkMMrBcHX4l

Asynchronous programming is an essential part of being a complete JavaScript programmer. Understanding the code execution flow in JavaScript is the foundation of understanding how it can handle asynchronous tasks. And being able to program asynchronous tasks enables you to take advantage of the extensive array of functions provided by JavaScript runtime engines and external APIs. With those tools you can connect your JavaScript programs with web APIs across the internet and effectively manage those—sometimes tenuous—connections.

With power comes complexity. Using JavaScript callbacks to implement asynchronous functionality can quickly create a series of deeply nested function calls. This post will show you how to write and organize your JavaScript callbacks to maximize the readability, maintainability, and reusability of your asynchronous functions.

As an old programming saying goes:

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

The first part of this series, Asynchronous JavaScript: Understanding Callbacks, provides a complete overview of the JavaScript event loop, callback queue, and execution stack. In a short case study project it shows you how the order of function execution works in JavaScripts non-blocking event model and how callbacks can be used to retrieve and manipulate data from external APIs.

This post picks up where the first part left off and show you how to convert a series of three linked callbacks and functions that's five levels deep into a more maintainable and debuggable code. Getting set up with the case study project will take just a few minutes.

Prerequisites

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

You should also have a well-grounded understanding of JavaScript and the JavaScript event loop. If you need a refresher on the latter point, check out the first part of this series on Asynchronous JavaScript.

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

Initializing the project

If you started this series from the beginning, great! You already have the project and code setup. You can continue with the asynchronous-javascript Node.js application.

If you already know how the event loop works and are here to focus on code organization there are two ways to get started:

Option 1

Clone the project from Part 1 by executing the following instructions in the directory where you'd like to create the project directory:

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

Option 2

Execute the following command-line instructions in the directory where you'd like to create the project directory:

mkdir asynchronous-javascript
cd asynchronous-javascript
git init
npx license mit > LICENSE
npx gitignore node
npm init -y
npm install request
git add -A
git commit -m "Initial commit"

After successfully completing option 1 or 2 you should have a Node.js project with a nestedCallbacks.js file in the project root directory. There's also a Git repo with an appropriate .gitignore file for a Node.js project.

If you followed Option 1, open the nestedCallbacks.js file in the root project directory.

If you followed Option 2, create a nestedCallbacks.js file in the root project directory and insert the following JavaScript code:

const request = require('request');

request(`https://maciejtreder.github.io/asynchronous-javascript/directors`, {json: true}, (err, res, directors) => {
  let tarantinoId = directors.find(director => director.name === "Quentin Tarantino").id;
  request(`https://maciejtreder.github.io/asynchronous-javascript/directors/${tarantinoId}/movies`, {json: true}, (err, res, movies) => {
      let checkedMoviesCount = 0;
      movies.forEach(movie => {
          request(`https://maciejtreder.github.io./asynchronous-javascript/movies/${movie.id}/reviews`, {json: true}, (err, res, reviews) => {
              checkedMoviesCount++;
              aggregatedScore = 0;
              count = 0;
              reviews.forEach(review => {
                  aggregatedScore += review.rating;
                  count++;
              });
              movie.averageRating = aggregatedScore / count;
              if (checkedMoviesCount === movies.length) {
                  movies.sort((m1, m2) => m2.averageRating - m1.averageRating);
                  console.log(`The best movie by Quentin Tarantino is... ${movies[0].title} !!!`);
              }
           });
      });
  });
});

Verify your Node.js project is set up correctly by executing the following command-line instruction in the root project directory:

node nestedCallbacks.js

The output displayed in your console window should be:

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

You may not agree with the result, but it demonstrates your code is working correctly and your API calls are connecting with the remote API mockup.

Understanding the problem with nested JavaScript callbacks

Before starting in on reorganizing your callbacks it will be helpful to have an understanding of the problem by taking a look at a series of nested callbacks.

Look at the nestedCallbacks.js code in your IDE or code editor. If you're using Visual Studio Code (and why wouldn't you?) you'll see that the forEach loop in the callback function for the third API call is at the sixth level of nesting.

While the code in the first two callbacks is simple and the third callback function is straightforward, it's not hard to see how finding your place in the callback queue could become complicated. Imagine if there were choices among API calls, callback functions, and arguments depending on conditional logic depending on the return values of the API calls. It could be a recipe for spaghetti!

Organizing your asynchronous JavaScript callbacks

In addition to being potentially difficult to read, maintain, and debug as the program grows in complexity, the API calls and callback functions in nestedCallbacks.js can't be reused in their current form. That means you're likely to have duplicate code, leading to more opportunities for bugs.

Fortunately, it's easy to refactor nestedCallbacks.js into more modular and reusable code. One technique is to start from the innermost callback and work your way up.

So you can compare the two versions side-by-side as you work, create an organizedCallbacks.js file in the project root directory.

Insert the following JavaScript code:

const request = require('request');

Encapsulate the most nested code block, which is the callback function for the last request call. Insert the following code below the line you just added:

function calculateAverageScore(reviews, movie, director, toBeChecked, movies) {
   toBeChecked.count--;
   aggregatedScore = 0;
   count = 0;
   reviews.forEach(review => {
       aggregatedScore += review.rating;
       count++;
   });
 
   movie.averageRating = aggregatedScore / count;
   if (toBeChecked.count === 0) {
       movies.sort((m1, m2) => m2.averageRating - m1.averageRating);
       console.log(`The best movie by ${director} is... ${movies[0].title} !!!`);
   }   
}

You may have noticed that there are more parameters in this function. Here's what they do:

movie is an object containing information about the movie for which you are going to calculate the average score.

reviews is an array of reviews of the specified movie. Each array element has a review score.

director is an object containing information about the director of the specified movie.

toBeChecked is a counter of how many movies remain to be processed by the function.

Note that toBeChecked is an object instead of a primitive (integer) type so its state, the number of movies checked, can be maintained between function calls. This wasn't necessary in the original code because of JavaScript's approach to variable scope: the variable checkedMoviesCount in the outer loop in the parent function is available to the function call below it. (Read more about JavaScript closures.) This is a technique you can use in many places where you need to keep track of values between calls to a function.

movies is an array of movies through which the function iterates to find the highest scoring.

The function iterates through the specified director's movies. It begins by incrementing the count counter. Then it iterates through each of the reviews for the movie and adds each review's score to the cumulative score and increments the count of reviews. When it finishes checking the reviews it calculates the average rating for the movie and adds the score to the movie's entry in the list of movies.

When the count of movies checked equals the length of the array of movies the code sorts the array in descending order by average score. The title of the first element in the array, which is the highest-rated movie, is then written to the console output.

Now move up the call stack to the second API call, which retrieves movie reviews.

Insert the following JavaScript code in organizedCallbacks.js below the code you added in the previous step:

function getReviews(movies, director) {
   let toBeChecked = {count: movies.length};
   movies.forEach(movie => {
       request(`https://maciejtreder.github.io/asynchronous-javascript/movies/${movie.id}/reviews`, {json: true}, (err, res, reviews) => calculateAverageScore(reviews, movie, director, toBeChecked, movies));
   });
}

This function takes two parameters:

movies is an array of movies for which you are going to retrieve reviews.

director is an object containing information about the director of the movies in the movies array.

The code initializes the toBeChecked counter, which keeps track of the number of movies remaining to be evaluated.

Then the code iterates through the movies array. It makes a REST call for each movie to retrieve a list of reviews, which is stored in the reviews object and passed to the calculateAverageScore function.

The last code block you are going to encapsulate is the callback for the first REST request, the code which looks up the movies for a given director:

function findDirector(directors, name) {
   let directorId = directors.find(director => director.name === name).id;
request(`https://maciejtreder.github.io/asynchronous-javascript/directors/${directorId}/movies`, {json: true}, (err, res, movies) => getReviews(movies, name));
}

There are two parameters:

directors is an array of directors to search.

name is a string identifying the director for whom you are retrieving reviews. It's used to display the final result. (For more on this, see the Further improvements section below.)

In the body of the function the code saves the id value for the specified director and uses it to perform a REST call to retrieve a list of his movies.

The callback function for the findDirector function is the getReviews function. When the REST call to the /asynchronous-javascript/directors/{id}/movies endpoint returns a list of movies,  the list is used as an argument to the callback function invocation of the getReviews function in the movies argument. The name of the director is passed in the name argument.

The web API call that starts the whole process is the call to the /directors endpoint to get a list of directors. Add the following code below the previous functions:

request(`https://maciejtreder.github.io/asynchronous-javascript/directors`, {json: true}, (err, res, directors) => findDirector(directors, 'Quentin Tarantino'));

The web API call gets a list of directors and parses the JSON object into an array. This list of directors is passed to the callback function in the directors argument. The callback function invocation of findDirector gets the id value for Quentin Tarantino.

Testing your reorganized JavaScript callbacks

Verify the new program structure is working by executing the following command-line instruction in the project's root directory:

node organizedCallbacks.js

The output should be:

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

Critics may differ, but the result reflects the average of the scores in the test API. If you're getting different results, be sure you're using the correct id value to get the list of movies for the director.

Exploring the advantages of encapsulated callback functions

What are the advantages of encapsulated callbacks? You have decreased number of nested levels in your code from six to three, making it easier to read, maintain, and debug. You can alter or add to the behavior of each function with less impact on the other functions.

You've also made it easier to expand the functionality of your application. Using the new program structure it's easy to find the highest-rated movie for each director in the test set.

Add the following lines to the bottom of the organizedCallbacks.js file:

request(`https://maciejtreder.github.io/asynchronous-javascript/directors`, {json: true}, (err, res, directors) => findDirector(directors, 'Stanley Kubrick'));
request(`https://maciejtreder.github.io/asynchronous-javascript/directors`, {json: true}, (err, res, directors) => findDirector(directors, 'James Cameron'));
request(`https://maciejtreder.github.io/asynchronous-javascript/directors`, {json: true}, (err, res, directors) => findDirector(directors, 'Wes Anderson'));

Execute the program again. You should see a list of the top-rated movies for four directors:

Best movie by Quentin Tarantino is... Inglourious Basterds !!!
Best movie by Stanley Kubrick is... 2001: A Space Odyssey !!!
Best movie by James Cameron is... Titanic !!!
Best movie by Wes Anderson is... The Grand Budapest Hotel !!!

If you haven't been coding along and want to catch to this step using the companion repository on GitHub, execute the following commands in the directory where you’d like to create the project directory:

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

Making further enhancements

The code above is an improvement over nestedCallbacks.js but there's still room for improvement. Here are some ways it can be improved:

  • It's upside down. Your programs will be more readable if the code from top to bottom is in the order of execution.
  • The functions are highly coupled. The program only works in the structure in which it's currently arranged and the flow of data between functions only permits the program to be structured in one way. This inhibits reusability of the functions and maintainability as requirements change.
  • The functions aren't atomic. You can see this easily in a couple places:
  • The code knows the name of the director for whom you're going to display the highest-scoring movie before the first API call is invoked, but it gets passed through two function calls.
  • The final function call, calculateAverageScore, displays the highest-rated movie—but that's "not part of its job description".

Doing more refactoring is a good exercise for you if you're new to JavaScript. The important thing to recognize is how much easier it is to see areas for improvement now that the level of callback nesting has been reduced. When you're doing your own programming and see that you've created a deeply-nested series of callbacks it's a sign you should consider refactoring.

Summary of Organizing JavaScript Callbacks

In this post you learned how to take a deeply-nested set of JavaScript callbacks and refactor them into separate functions, making the code more readable, maintainable, and debuggable. You also saw how reducing the nesting of JavaScript callbacks can make it easier to see other areas in which the code can be improved.

Additional resources

JavaScript: The Good Parts, Douglas Crockford (O'Reilly, 2008) If you are only going to read one book about writing JavaScript, this should be it.

json.org The JSON language specification on one page. Also available in a longer PDF form from ECMA.

GitHub Pages is used to host the test web API used in this post. It's a good thing to know about.

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.

Updated 2020-01-27