If you haven’t already guessed, this post will walk you through all things promises, callbacks, and JavaScript’s newest flow control method, async/await. But before we get started, I’d like to paint a picture for you.
Imagine you need to go to the store to buy some food. How would you get there? I would guess that for most of you, a car would be the preferred choice. So, if you were all ready to leave, do you just get into your car and arrive at the store? Or are there several steps you need to follow before that? Typically, there are several actions that must be completed in a certain sequence before you can actually get to the store. If you are like me, you might do the following:
- 1. Open the car door
- 2. Sit down
- 3. Put on the seat belt
- 4. Check mirrors
- 5. Start the car
- 6. Check for objects or cars around you
- 7. Proceed to your destination
Lets look at the last two steps. Before you drive, you check for objects around you. You wouldn’t just blindly step on the accelerator and hope for the best would you? Of course not! So, you take a few seconds, look around, and then drive.
But, what does this have to do with JavaScript? I am glad you asked!
Table of contents
Control Flow
JavaScript is single threaded, which means that everything happens in the order it is written. If we were to write a function that dealt with driving to the store, it might look like this:
function driveToStore(callback){
console.log('opening the car door');
console.log('sitting down');
console.log('putting on the seat belt');
console.log('checking mirrors');
console.log('starting the car');
setTimeout(function() {
console.log('checking for other cars');
}, 1000);
console.log('driving to the store');
}
driveToStore();
But wait! Did you notice something? We started driving before we finished checking for other cars! That is dangerous!
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Because JavaScript executes things in the order they are written, it was doing what we told it to, otherwise known as ‘synchronous’ processing. We started the car, executed the setTimeout
function and then drove, but JavaScript didn’t wait for us to finish because we didn’t tell it to. It just executed the setTimeout
function and kept going. This is where JavaScript’s control flow structures come in.
Callbacks
What is a Callback, and why do we use it in JavaScript?
According to Wikipedia, a callback is
any executable code that is passed as an argument to other code that is expected to call back (execute) the argument at a given time.
In other words, callback functions are functions that execute after another function has been run, and are typically passed as a parameter to another function. Just for reference, a function that accepts another function as an argument is called a ‘high order function’.
This is an example of a callback function:
function driveToStore(callback){
console.log("opening the car door");
console.log("sitting down");
console.log("putting on the seat belt");
console.log("checking mirrors");
console.log("starting the car");
callback();
console.log("driving to the store");
}
function checkForCars(){
console.log('checking for other cars');
}
driveToStore(checkForCars);
What happened here? We created a new function called checkForCars
and passed it to the driveToStore
function as a callback. From within the driveToStore
function, we can then execute the checkForCars
function.
If you run it, you will see that the sequence of driving to the store happens in the intended order, meaning that we wait to finish checking for other cars before we start driving, and thus avoid programming fender bender!
Use case
Callback functions are useful in JavaScript any time we want to execute some long running code in a function and wait for the result before executing the next task. One such example is making a database call to get data back, and then returning results based on that data. To expand on this idea, think of a website login. What does the typical workflow look like? From a high level, it might look something like this:
- User enters login credentials
- User clicks a button on the front end
- On click event, frontend makes an POST request to the backend API
- Backend takes the data, sends it to the database
- Database is queried, and then sends back results
- Backend forwards results to the front end
- Frontend displays results
A user signs in, and during the process the database is queried to see if the user exists, if they do, the database returns user information, or it might make another request to get additional user information based on the user’s ID.
If we are using JavaScript, especially on the backend with Node.js, you might run into issues when handling requests. If your code is not structured properly, you could be responding to the frontend request with empty or incomplete data.
A shorthand example of how this would might act out in real life is as follows (note - this is not production ready code!):
<button id="login">Login!</button>
<div>
<div>
<h1>User Greeting</h1>
<div id="greeting"></div>
</div>
<div>
<p id="posts"></p>
</div>
</div>
The code that would handle the request might look like this:
document
.getElementById("login")
.addEventListener("click", function() {
sendRequest("Tommy");
});
function sendRequest(username, callback) {
checkDbForUser(username, function(response) {
if (response.error) {
document.getElementById("greeting")
.innerHTML = "Sorry, no user found";
return;
} else {
checkDbForPosts(response.userId, function(response) {
if (response.error) {
document.getElementById("posts")
.innerHTML = "Sorry, no posts found";
return;
} else {
document.getElementById("greeting")
.innerHTML = `Welcome back ${username}`;
document.getElementById("posts")
.innerHTML = `Here is your post: ${response.posts[0].post}`;
}
})
}
})
}
function checkDbForUser(username, callback) {
setTimeout(function() {
if (username != 'Tommy') {
callback({ error: true, userId: null })
} else {
callback({ error: false, userId: 1 })
}
}, 2000);
}
function checkDbForPosts(userId, callback) {
setTimeout(function() {
if (userId == 1) {
callback({ error: false, posts: [{ postId: 1, post: 'Post 1' }] })
} else {
callback({ error: true, posts: null })
}
}, 1000);
}
Woah. That is a lot of nested code. When you start nesting more than 2 levels deep, this is a ‘code smell’ known as ‘callback hell’. Basically, you get to a point where you start creating so many nested levels deep your code becomes brittle and unreadable. But never fear, we have ways to fix this!
Promises
If you don’t want to fall into trap of ‘callback hell’, promises are another way of calling long running code and waiting for a result to come back. As with any long running code, we don’t know when it will return a successful or failed response, but we just know that we will eventually get a result back. Thats what promises do for us.
Promises wait for code to return a response, and then
they resolve
the successful result, or reject
the error. These resolve
and reject
properties are passed into a promise as parameters to a callback function (remember those?).
To see this in practice, lets take our sendRequest
function and convert it to one that uses promises.
function sendRequest(username) {
checkDbForUser(username)
.then(function(response) {
return checkDbForPosts(response.userId)
})
.then(function(response) {
document.getElementById("greeting")
.innerHTML = `Welcome back ${username}`;
document.getElementById("posts")
.innerHTML = `Here is your post: ${response.posts[0].post}`;
})
.catch(function(error) {
document.getElementById("greeting")
.innerHTML = "Sorry, we couldnt find the user";
return;
})
}
function checkDbForUser(username) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (username != 'Tommy') {
reject({ error: true, userId: null })
} else {
resolve({ error: false, userId: 1 })
}
}, 200);
})
}
function checkDbForPosts(userId) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (userId == 1) {
resolve({ error: false, posts: [{ postId: 1, post: 'Post 1' }] })
} else {
reject({ error: true, posts: null })
}
}, 100);
})
}
The code here is much more concise, and has a flatter structure. By returning a promise, we can pass results through to the next one and chain the results. With the added catch
, we will return any error thrown in the promise chain.
This is all good and well, but what if we have to run functions that don’t depend on each other? We can then take advantage of promise.all
and run multiple functions at the same time, and when they have all finished, do something with their collective results(which are returned as an array). If you don’t like that, you can access each result by its index, cooresponding to where the function order in the promise.
A silly example of this can be when I work on the computer. I could be doing several things at once, such as listening to music, reading, and typing. If my brain were JavaScript powered, I would use promise.all
to handle all of the events.
const listenToMusic = Promise.resolve('listening to music');
const readSentence = new Promise(function(resolve, reject) {
setTimeout(resolve, 5000, 'I am reading, give me some time to finish');
});
const typeSomeCode = new Promise(function(resolve, reject) {
setTimeout(resolve, 1000, 'let me type this real quick');
});
Promise.all([listenToMusic, readSentence, typeSomeCode])
.then(function(collectiveResults) {
console.log(collectiveResults);
console.log("listen results", collectiveResults[0])
});
Async/Await
The final, and most recent control structure in JavaScript is Async/Await. All this is doing is putting syntax sugar on top of promises, basically adding further abstraction to make the code more readable and/or less verbose.
Lets take our sendRequest
function and convert it to one that uses Async/Await.
async function sendRequest(username) {
try {
let userResponse = await checkDbForUser(username)
let postResponse = await checkDbForPosts(userResponse.userId)
document.getElementById("greeting")
.innerHTML = `Welcome back ${username}`;
document.getElementById("posts")
.innerHTML = `Here is your post: ${postResponse.posts[0].post}`;
} catch {
document.getElementById("greeting")
.innerHTML = "Sorry, we couldnt find the user";
}
}
function checkDbForUser(username) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (username != 'Tommy') {
reject({
error: true,
userId: null
})
} else {
resolve({
error: false,
userId: 1
})
}
}, 200);
})
}
function checkDbForPosts(userId) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (userId == 1) {
resolve({
error: false,
posts: [{
postId: 1,
post: 'Post 1'
}]
})
} else {
reject({
error: true,
posts: null
})
}
}, 100);
})
}
Here, you can see we are using try
/catch
to handle errors, and have added the async
keyword to the sendRequest
function, which tells the function to automatically wrap the returned value in a resolved promise. The await
keyword specifies that the function call has to wait until a promised is resolved before moving on. You can only use the await
keyword inside of an async
function, or you will otherwise get a syntax error.
Summary
Now that we have reviewed each control structure, it is time to think about how each one fits in to your programming style. Callbacks existed in a world without promises, and were (outside of third party libraries) the only way to handle nested, dependent function calls in pure JavaScript. While this is good for keeping out libraries, it is bad for maintenance and readability, because the code can become so deeply nested you get stuck in ‘callback hell’.
The more recent structure of handling asynchronous code, promises, offers a more readable approach to handling code, but if you are not careful, can still get stuck in what is known as ‘promise hell’ if you don’t properly make use of promise chaining (keep adding .then
!)
Finally, with ES6 we get to make use of Async/Await, and the full power of asynchronous flow control is now at the tips of our fingers. While still based on promises, they abstract away some of the verbosity with promise chaining and error handling, making our code more readable still.