A common pattern to handle asynchronous events is to use callbacks. As these callbacks are nested to chain operations, the code gets difficult to write and even more difficult to understand. Promises are special objects in Javascript that can be used to avoid the callback hell. The resulting code is more readable and easier to write.
In this post, we’ll show what is the callback hell and explain how Promises can be used to avoid it.
Callback functions
Let’s start with a simple example. Let’s suppose we’re retrieving a value from an external network. This request can result in a success – we get the value we need – or fail (for example if the external server isn’t operational).
We’ll emulate this situation with the following code.
function fetchValue(resolve, reject) { let val; if (Math.random() > 0.5) val = (Math.random() * 10).toFixed(0); if (val) resolve(val); else reject(val); } function successCallback(val) { console.log(`Succesfully retrieved ${val}!`); } function failureCallback(val) { console.error(`Invalid value! Response was ${val}`); } fetchValue(successCallback, failureCallback);
Function fetchValue
returns a random value between 0
and 9
. To simulate a failure in the process of retrieving that value, there’s a 50% chance of returning and undefined
value instead. On success, successCallback
is called and it prints the value. If it fetchValue
fails, it will print an error message by calling failureCallback
.
Up to this point, the code is easy to understand. Let’s see, however, what happens when we start chaining operations, even simple ones.
Callback hell
Let’s add two extra operations to the example above. Let’s say that after retrieving the value, we check if it’s even and also if it’s divisible by three. We could perform all of those checks in a single function. For illustration purposes, we’ll chain the operations as it’s sometimes required using callbacks.
The two extra callbacks will be:
function checkParity(val, resolve, reject) { if (val % 2 == 0) resolve(val); else reject(val); } function divisibleByThree(val, resolve, reject) { if (val % 3 == 0) resolve(val); else reject(val); }
They will receive a value, val
, coming from fetchValue
. For both, we call the resolve
function on success and the reject
function on failure.
The corresponding failure callbacks when the value isn’t even or isn’t divisible by three are:
function failureCallbackParity(val) { console.error("Value ", val, " isn't even!"); } function failureCallbackDiv3(val) { console.error("Value ", val, " isn't divisible by 3!"); }
Here comes the ugly part. Chaining those callbacks, we’ll get the following code:
fetchValue(function (value) { console.log(`Succesfully retrieved ${value}!`); checkParity( value, function (evenValue) { console.log(`Value ${evenValue} is even!`); divisibleByThree( evenValue, function (finalValue) { console.log(`Value ${finalValue} is divisible by 3!`); }, failureCallbackDiv3 ); }, failureCallbackParity ); }, failureCallback);
Even with the detailed explanation that came before, understanding this piece of code isn’t easy. This is what we call the callback hell (aka pyramid of doom).
Promises
Promises were introduced in JavaScript to handle deferred results. By deferred, we refer to operations that take an unknown amount of time to complete at the time they are called. For example, fetching data from an external network. They are particularly helpful when dealing with asynchronous events.
Let’s see how to refactor the example above using Promise
s.
Single callback
The fetchValue
function can be refactored using Promise
s as follows:
function fetchValue() { let val; if (Math.random() > 0.5) val = (Math.random() * 10).toFixed(0); return new Promise(function (resolve, reject) { if (val) return resolve(val); else return reject(`Invalid value! Response was ${val}`); }); }
See that we return a Promise
object. Its constructor accepts a function with parameters resolve
and reject
. They correspond to successCallback
and failureCallback
from the example above. We have refactored the reject
callback to accept the whole error message instead of the single value.
The callbacks are:
function successCallback(val) { console.log(`Succesfully retrieved ${val}!`); } function failureCallback(err) { console.error(err); }
Then, we can chain the fetchValue
function (that returns a Promise
) with the callbacks as:
fetchValue().then(successCallback, failureCallback);
The process takes place as follows:
fetchValue
is called and returns aPromise
.Promise
s have the special methodthen
, to which we passsuccessCallback
andfailureCallback
. They will be called in place of theresolve
andreject
functions, respectively.
In our example, the Promise
resolves instantly and either of successCallback
or failureCallback
are called. In a more general situation, where the Promise
takes some time to process, the resolve
or reject
functions are called only if and when the Promise
resolves.
A glimpse of the asynchronous nature of Promises can be seen by adding a console.log
after calling fetchValue
.
fetchValue().then(successCallback, failureCallback); console.log("First!");
Even though fetchValue
is called first, the message First!
will show on the console first. Roughly speaking, this is because Promise
s are scheduled for execution and belong to a special queue of events. They aren’t part of the “normal” execution flow of the program.
Finally, we can chain the reject
callback using catch
instead:
fetchValue().then(successCallback).catch(failureCallback);
We’ll see how using catch
is helpful when chaining callbacks below.
Multiple callbacks
The usefulness of Promises can be more clearly appreciated when we chain callbacks. We saw above that chaining callbacks results in the infamous callback hell. Let’s see how Promises can come to the rescue.
First, let’s redefine the extra callbacks to use Promises.
function checkParity(val) { if (val % 2 == 0) return val; throw `Value ${val} isn't even!`; } function divisibleByThree(val, resolve, reject) { if (val % 3 == 0) return val; throw `Value ${val} isn't divisible by 3!`; }
There are three main differences. First, they accept a single value, val
. Second, they directly return the result instead of calling a callback. Third, instead of calling the reject
function they use a throw
statement. This statement is equivalent in this context to return Promise.reject(val)
.
We’ll use a single function as the reject
callback:
function failureCallback(err) { console.error(err); }
The resulting chaining of callbacks using Promises is presented below:
fetchValue() .then((value) => { console.log(`Succesfully retrieved ${value}!`); return checkParity(value); }) .then((evenValue) => { console.log(`Value ${evenValue} is even!`); return divisibleByThree(evenValue); }) .then((finalValue) => console.log(`Value ${finalValue} is divisible by 3!`)) .catch(failureCallback);
This is already an improvement:
- There’s no nesting.
- The callbacks aren’t intertwined.
- There’s a single failure callback to handle all errors.
The last catch
expression will handle any of the throw
statements from the callbacks.
The chaining of then
expressions can be done because each then
, by default, returns a new Promise
.
This callback chain can be simplified even further if our only purpose is retrieving a single valid value after all of the operations.
We may be tempted to write it as follows:
let value = fetchValue() .then(checkParity) .then(divisibleByThree) .catch(failureCallback);
However, as it was stated before, all then
statements return a Promise
. The code above will assign a Promise
to value
.
As a workaround, we have the following:
let value; fetchValue() .then(checkParity) .then(divisibleByThree) .then((finalValue) => value = finalValue) .catch(failureCallback);
The purpose of the last callback is to assign finalValue
to value
. This is possible because inside of the then
statement, the “value” of the Promise is accessible.
This last code block is very telling. Instead of the callback hell that we first presented, we have just a few lines of very clear code that even resembles spoken language.
As a final note, Promise
s are generally used in the context of asynchronous events. While they can be handled with the then
and catch
methods that we presented here, a preferred approach is to use async
and await
keywords (introduced to JavaScript with ES2017).
References
- Promises (MDN).
- Using promises (MDN).