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()
andPromise.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()
andonRejected()
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()
noronRejected()
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()
oronRejected()
return a valuex
, andx
is a promise,promise2
will lock in with (assume the same state and value as)x
. Otherwise,promise2
will be fulfilled with the value ofx
.If either
onFulfilled
oronRejected
throws an exceptione
,promise2
must be rejected withe
as the reason.If
onFulfilled
is not a function andpromise1
is fulfilled,promise2
must be fulfilled with the same value aspromise1
.If
onRejected
is not a function andpromise1
is rejected,promise2
must be rejected for the same reason aspromise1
.
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.
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:
We declare the function as
async
, indicating that it will contain asynchronous operations.We use the
await
keyword before thefetch
andresponse.json()
calls. This tells JavaScript to pause the function's execution until the Promise resolves.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!