How JavaScript Promises Work – Handbook for Beginners
![How JavaScript Promises Work – Handbook for Beginners](https://talalzaman.com/wp-content/uploads/2024/02/How-JavaScript-Promises-Work-Cover.png)
[ad_1]
Many operations, such as network requests, are asynchronous in nature. One of the most useful and powerful tools for working with asynchronous code is the Promise. In this handbook, you’ll learn all about JavaScript Promises and how to use them.
Table of Contents
- What is a Promise?
- Comparing Promises to Other Async Patterns
- How to Create a Promise
- How to Get the Result of a Promise
- How to Handle Errors with
then
- Promise Chaining
- How to Create Immediately Fulfilled or Rejected Promises
- How to Use
async
andawait
- Promise Anti-Patterns
- Summary
What is a Promise?
Let’s begin by looking at what a Promise is.
In simple terms, a Promise is an object representing an asynchronous operation. This object can tell you when the operation succeeds, or when it fails.
When you call a Promise-based API, the function returns a Promise object that will eventually provide the result of the operation.
Promise states
During its lifetime, a Promise can be in one of three states:
- Pending: A Promise is pending while the operation is still in progress. It’s in an idle state, waiting for the eventual result (or error).
- Fulfilled: The asynchronous task that returned the Promise completed successfully. A Promise is fulfilled with a value, which is the result of the operation.
- Rejected: If the asynchronous operation failed, the Promise is said to be rejected. A Promise is rejected with a reason. This typically is an
Error
object, but a Promise can be rejected with any value – even a simple number or string!
A Promise starts out in the pending state, then depending on the result, will transition to either the fulfilled or rejected state. A Promise is said to be settled once it reaches either the fulfilled or rejected state.
Of course, there is no guarantee that the asynchronous task will ever complete. It’s completely possible for a Promise
to remain in the pending state forever, though this would be because of a bug in the asynchronous task’s code.
Comparing Promises to Other Async Patterns
Promises behave a little differently from other asynchronous patterns in JavaScript. Before diving deeper into Promises, let’s briefly compare Promises to these other techniques.
Callback functions
A callback function is a function that you pass to another function. When the function you call has finished its work, it will execute your callback function with the result.
Imagine a function called getUsers
which will make a network request to get an array of users. You can pass a callback function to getUsers
, which will be called with the array of users once the network request is complete:
console.log('Preparing to get users');
getUsers(users => {
console.log('Got users:', users);
});
console.log('Users request sent');
First, the above code will print “Preparing to get users”. Then it calls getUsers
which will initiate the network request. But JavaScript doesn’t wait for the request to complete. Instead, it immediately executes the next console.log
statement.
Later, once the users have been loaded, your callback will be executed and “Got users” will be printed.
Some callback-based APIs, such as many Node.js APIs, use error-first callbacks. These callback functions take two arguments. The first argument is an error, and the second is the result.
Typically, only one of these will have a value, depending on the outcome of the operation. This is similar to the fulfilled and rejected Promise states.
The trouble with callback APIs is that of nesting. If you need to make multiple asynchronous calls in sequence, you’ll end up with nested function calls and callbacks.
Imagine you want to read a file, process some data from that file, then write a new file. All three of these tasks are asynchronous and use an imaginary callback based API.
readFile('sourceData.json', data => {
processData(data, result => {
writeFile(result, 'processedData.json', () => {
console.log('Done processing');
});
});
});
It gets even more unwieldy with error handling. Imagine these functions used error-first callbacks:
readFile('sourceData.json', (error, data) => {
if (error) {
console.error('Error reading file:', error);
return;
}
processData(data, (error, result) => {
if (error) {
console.error('Error processing data:', error);
return;
}
writeFile(result, 'processedData.json', error => {
if (error) {
console.error('Error writing file:', error);
return;
}
console.log('Done processing');
});
});
});
Callback functions aren’t typically used directly as an asynchronous mechanism in modern APIs, but as you’ll soon see, they are the foundation for other types of asynchronous tools such as Promises.
Events
An event is something that you can listen for and respond to. Some objects in JavaScript are event emitters, which means you can register event listeners on them.
In the DOM, many elements implement the EventTarget
interface which provides addEventListener
and removeEventListener
methods.
A given type of event can occur more than once. For example, you can listen for the click event on a button:
myButton.addEventListener('click', () => {
console.log('button was clicked!');
});
Every time the button is clicked, the text “button was clicked!” will be printed to the console.
addEventListener
itself accepts a callback function. Whenever the event occurs, the callback is executed.
An object can emit multiple types of events. Consider an image object. If the image at the specified URL is loaded successfully, the load
event is triggered. If there was an error, this event is not triggered and instead the error
event is triggered.
myImage.addEventListener('load', () => {
console.log('Image was loaded');
});
myImage.addEventListener('error', error => {
console.error('Image failed to load:', error);
});
Suppose the image already completed loading before you added the event listener. What do you think would happen? Nothing! One drawback of event-based APIs is that if you add an event listener after an event, your callback won’t be executed. This makes sense, after all – you wouldn’t want to receive all past click events when you add a click listener to a button.
Now that we’ve explored callbacks and events, let’s take a closer look at Promises.
How to Create a Promise
You can create a Promise using the new
keyword with the Promise
constructor. The Promise
constructor takes a callback function that takes two arguments, called resolve
and reject
. Each of these arguments is a function provided by the Promise, which are used to transition the Promise to either the fulfilled or rejected state.
Inside your callback, you perform your asynchronous work. If the task is successful, you call the resolve
function with the final result. If there was an error, you call the reject
function with the error.
Here’s an example of creating a Promise that wraps the browser’s setTimeout
function:
function wait(duration) {
return new Promise(resolve => {
setTimeout(resolve, duration);
});
}
The resolve
function is passed as the first argument to setTimeout
. After the time specified by duration
has passed, the browser calls the resolve
function which fulfills the Promise.
Note: In this example, the delay before the resolve
function is called may be longer than the duration passed to the function. This is because setTimeout
does not guarantee execution at the specified time.
It’s important to note that often times, you won’t actually need to construct your own Promise by hand. You will typically be working with Promises returned by other APIs.
How to Get the Result of a Promise
We’ve seen how to create a Promise, but how do you actually get the result of the asynchronous operation? To do this, you call then
on the Promise object itself. then
takes a callback function as its argument. When the Promise is fulfilled, the callback is executed with the result.
Let’s see an example of this in action. Imagine a function called getUsers
that asynchronously loads a list of user objects and returns a Promise. You can get the list of users by calling then
on the Promise returned by getUsers
.
getUsers()
.then(users => {
console.log('Got users:', users);
});
then
on a PromiseJust like with events or callback based APIs, your code will continue executing without waiting for the result. Some time later, when the users have been loaded, your callback is scheduled for execution.
console.log('Loading users');
getUsers()
.then(users => {
console.log('Got users:', users);
});
console.log('Continuing on');
In the above example, “Loading users” will be printed first. The next thing that is printed will be “Continuing on”, because the getUsers
call is still loading the users. Later, you’ll see “Got users” printed.
How to Handle Errors with then
We’ve seen how to use then
to get the result provided to the Promise, but what about errors? What happens if we fail to load the user list?
The then
function actually takes a second argument, another callback. This is the error handler. If the Promise is rejected, this callback is executed with the rejection value.
getUsers()
.then(users => {
console.log('Got users:', users);
}, error => {
console.error('Failed to load users:', error);
});
Since a Promise can only ever be either fulfilled or rejected, but not both, only one of these callback functions will be executed.
It’s important to always handle errors when working with Promises. If you have a Promise rejection that isn’t handled by an error callback, you’ll get an exception in your console about an unhandled rejection, which can cause issues for your users at runtime.
Promise Chaining
What if you need to work with multiple Promises in series? Consider the earlier example where we loaded some data from a file, did some processing, and wrote the result to a new file. Suppose the readFile
, processData
, and writeFile
functions used Promises instead of callbacks.
You might try something like this:
readFile('sourceData.json')
.then(data => {
processData(data)
.then(result => {
writeFile(result, 'processedData.json')
.then(() => {
console.log('Done processing');
});
});
});
This doesn’t look great, and we still have the nesting issue that we had with the callback approach. Thankfully, there is a better way. You can chain Promises together in a flat sequence.
To see how this works, let’s look deeper at how then
works. The key idea is this: the then
method returns another Promise. Whatever value you return from your then
callback becomes the fulfilled value of this new Promise.
Consider a getUsers
function that returns a Promise that gets fulfilled with an array of user objects. Suppose we call then
on this Promise, and in the callback, return the first user in the array (users[0]
):
getUsers().then(users => users[0]);
This whole expression, then, results in a new Promise that will be fulfilled with the first user object!
getUsers()
.then(users => users[0])
.then(firstUser => {
console.log('First user:', firstUser.username);
});
then
handlerThis process of returning a Promise, calling then
, and returning another value, resulting in another Promise, is called chaining.
Let’s extend this idea. What if, instead of returning a value from the then
handler, we returned another Promise? Consider again the file-processing example, where readFile
and processData
are both asynchronous functions that return Promises:
readFile('sourceData.json')
.then(data => processData(data));
then
The then
handler calls processData
, returning the resulting Promise. As before, this returns a new Promise. In this case, the new Promise will become fulfilled when the Promise returned by processData
is fulfilled, giving you the same value. So the code in the above example would return a Promise that will be fulfilled with the processed data.
You can chain multiple Promises, one after the other, until you get to the final value you need:
readFile('sourceData.json')
.then(data => processData(data))
.then(result => writeFile(result, 'processedData.json'))
.then(() => console.log('Done processing'));
In the above example, the whole expression will result in a Promise that won’t be fulfilled until after the processed data is written to a file. “Done processing!” will be printed to the console, and then the final Promise will become fulfilled.
Error handling in Promise chains
In our file-processing example, an error can occur at any stage in the process. You can handle an error from any step in the Promise chain by using the Promise’s catch
method.
readFile('sourceData.json')
.then(data => processData(data))
.then(result => writeFile(result, 'processedData.json'))
.then(() => console.log('Done processing'))
.catch(error => console.log('Error while processing:', error));
catch
If one of the Promises in the chain is rejected, the callback function passed to catch
will execute and the rest of the chain is skipped.
How to use finally
You might have some code you want to execute regardless of the Promise result. For example, maybe you want to close a database or a file.
openDatabase()
.then(data => processData(data))
.catch(error => console.error('Error'))
.finally(() => closeDatabase());
How to use Promise.all
Promise chains let you run multiple tasks in sequence, but what if you want to run multiple tasks at the same time, and wait until they all complete? The Promise.all
method lets you do just that.
Promise.all
takes an array of Promises, and returns a new Promise. This Promise will be fulfilled once all of the other Promises are fulfilled. The fulfillment value is an array containing the fulfillment values of each Promise in the input array.
Suppose you have a function loadUserProfile
that loads a user’s profile data, and another function loadUserPosts
that loads a user’s posts. They both take a user ID as the argument. There’s a third function, renderUserPage
, that needs both the profile and list of posts.
const userId = 100;
const profilePromise = loadUserProfile(userId);
const postsPromise = loadUserPosts(userId);
Promise.all([profilePromise, postsPromise])
.then(results => {
const [profile, posts] = results;
renderUserPage(profile, posts);
});
Promise.all
What about errors? If any of the Promises passed to Promise.all
is rejected with an error, the resulting Promise is also rejected with that error. If any of the other Promises are fulfilled, those values are lost.
How to use Promise.allSettled
The Promise.allSettled
method works similarly to Promise.all
. The main difference is that the Promise returned by Promise.allSettled
will never be rejected.
Instead, it is fulfilled with an array of objects, whose order corresponds to the order of the Promises in the input array. Each object has a status
property which is either “fulfilled” or “rejected”, depending on the result.
If status
is “fulfilled”, the object will also have a value
property indicating the Promise’s fulfillment value. If status
is “rejected”, the object will instead have a reason
property which is the error or other object the Promise was rejected with.
Consider again a getUser
function that takes a user ID and returns a Promise that is fulfilled with the user having that ID. You can use Promise.allSettled
to load these in parallel, making sure to get all users that were loaded successfully.
Promise.allSettled([
getUser(1),
getUser(2),
getUser(3)
]).then(results => {
const users = results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
console.log('Got users:', users);
});
You can make a general purpose loadUsers
function that loads users, in parallel, given an array of user IDs. The function returns a Promise that is fulfilled with an array of all users that were successfully loaded.
function getUsers(userIds) {
return Promise.allSettled(userIds.map(id => getUser(id)))
.then(results => {
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
});
}
Then, you can just call getUsers
with an array of user IDs:
getUsers([1, 2, 3])
.then(users => console.log('Got users:', users));
getUsers
helper functionSometimes, you may want to wrap a value in a fulfilled Promise. For example, maybe you have an asynchronous function that returns a Promise, but there is a base case where you know the value ahead of time and you don’t need to do any asynchronous work.
To do this, you can call Promise.resolve
with a value. This returns a Promise that is immediately fulfilled with the value you specified:
Promise.resolve('hello')
.then(result => {
console.log(result); // prints "hello"
});
Promise.resolve
This is more or less equivalent to the following:
new Promise(resolve => {
resolve('hello');
}).then(result => {
console.log(result); // also prints "hello"
});
To make your API more consistent, you can create an immediately fulfilled Promise and return that in such cases. This way, the code that calls your function knows to always expect a Promise, no matter what.
For example, consider the getUsers
function defined earlier. If the array of user IDs is empty, you could simply return an empty array because no users will be loaded.
function getUsers(userIds) {
// immediately return the empty array
if (userIds.length === 0) {
return Promise.resolve([]);
}
return Promise.allSettled(userIds.map(id => getUser(id)))
.then(results => {
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
});
}
getUsers
helper functionAnother use for Promise.resolve
is to handle the case where you are given a value that may or may not be a Promise, but you want to always treat it as a Promise.
You can safely call Promise.resolve
on any value. If it was already a Promise, you’ll just get another Promise that will have the same fulfillment or rejection value. If it was not a Promise, it will be wrapped in an immediately fulfilled Promise.
The benefit of this approach is you don’t have to do something like this:
function getResult(result) {
if (result.then) {
result.then(value => {
console.log('Result:', value);
});
} else {
console.log('Result:', result);
}
}
then
based on whether or not something is a PromiseSimilarly, you can create an immediately rejected Promise with Promise.reject
. Returning once again to the getUsers
function, maybe we want to immediately reject if the user ID array is null
, undefined
, or not an array.
function getUsers(userIds) {
if (userIds == null || !Array.isArray(userIds)) {
return Promise.reject(new Error('User IDs must be an array'));
}
// immediately return the empty array
if (userIds.length === 0) {
return Promise.resolve([]);
}
return Promise.allSettled(userIds.map(id => getUser(id)))
.then(results => {
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
});
}
How to use Promise.race
Just like Promise.all
or Promise.allSettled
, the Promise.race
static method takes an array of Promises, and returns a new Promise. As the name implies, though, it works somewhat differently.
The Promise returned by Promise.race
will wait until the first of the given Promises is fulfilled or rejected, and then that Promise will also be fulfilled or rejected, with the same value. When this happens, the fulfilled or rejected values of the other Promises are lost.
How to use Promise.any
Promise.any
works similarly to Promise.race
with one key difference – where Promise.race
will be done as soon as any Promise is fulfilled or rejected, Promise.any
waits for the first fulfilled Promise.
How to Use async
and await
async
and await
are special keywords that simplify working with Promises. They remove the need for callback functions and calls to then
or catch
. They work with try-catch blocks, as well.
Here’s how it works. Instead of calling then
on a Promise, you await
it by putting the await
keyword before it. This effectively “pauses” execution of the function until the Promise is fulfilled.
Here’s an example using standard Promises:
getUsers().then(users => {
console.log('Got users:', users);
});
then
Here’s the equivalent code using the await
keyword:
const users = await getUsers();
console.log('Got users:', users);
await
Promise chains are a little cleaner, too:
const data = await readFile('sourceData.json');
const result = await processData(data);
await writeFile(result, 'processedData.json');
await
Remember that each usage of await
will pause execution of the rest of the function until the Promise you are awaiting becomes fulfilled. If you want to await several Promises that run in parallel, you can use Promise.all
:
const users = await Promise.all([getUser(1), getUser(2), getUser(3)]);
await
To use the await
keyword, your function must be marked as an async function. You can do this by placing the async
keyword before your function:
async function processData(sourceFile, outputFile) {
const data = await readFile(sourceFile);
const result = await processData(data);
writeFile(result, outputFile);
}
async
Adding the async
keyword also has another important effect on the function. Async functions always implicitly return a Promise. If you return a value from an async function, the function will actually return a Promise that is fulfilled with that value.
async function add(a, b) {
return a + b;
}
add(2, 3).then(sum => {
console.log('Sum is:', sum);
});
async
function to add two numbersIn the above example, the function is returning the sum of the two arguments a
and b
. But since it’s an async
function, it doesn’t return the sum but rather a Promise that is fulfilled with the sum.
Error handling with async
and await
We use await
to wait for Promise to be fulfilled, but what about handling errors? If you are awaiting a Promise, and it is rejected, an error will be thrown. This means to handle the error, you can put it in a try-catch block:
try {
const data = await readFile(sourceFile);
const result = await processData(data);
await writeFile(result, outputFile);
} catch (error) {
console.error('Error occurred while processing:', error);
}
Promise Anti-Patterns
Unnecessarily creating a new Promise
Sometimes there’s no getting around creating a new Promise. But if you are already working with Promises returned by an API, you usually shouldn’t need to create your own Promise:
function getUsers() {
return new Promise(resolve => {
fetch('https://example.com/api/users')
.then(result => result.json())
.then(data => resolve(data))
});
}
In this example, we’re creating a new Promise to wrap the Fetch API, which already returns Promises. This is unnecessary. Instead, just return the Promise chain from the Fetch API directly:
function getUsers() {
return fetch('https://example.com/api/users')
.then(result => result.json());
}
In both cases, the code calling getUsers
looks the same:
getUsers()
.then(users => console.log('Got users:', users))
.catch(error => console.error('Error fetching users:', error));
getUsers
functionSwallowing errors
Consider this version of a getUsers
function:
function getUsers() {
return fetch('https://example.com/api/users')
.then(result => result.json())
.catch(error => console.error('Error loading users:', error));
}
Error handling is good, right? You might be surprised by the result if we call this getUsers
function:
getUsers()
.then(users => console.log('Got users:', users))
.catch(error => console.error('error:', error);)
getUsers
You might expect this to print “error”, but it will actually print “Got users: undefined”. This is because the catch
call “swallows” the error and returns a new Promise that is fulfilled with the return value of the catch
callback, which is undefined
(console.error
returns undefined
). You’ll still see the “Error loading users” log message from getUsers
, but the returned Promise will be fulfilled, not rejected.
If you want to catch the error inside the getUsers
function and still reject the returned Promise, the catch
handler needs to return a rejected Promise. You can do this by using Promise.reject
.
function getUsers() {
return fetch('https://example.com/api/users')
.then(result => result.json())
.catch(error => {
console.error('Error loading users:', error);
return Promise.reject(error);
});
}
Now you’ll still get the “Error loading users” message, but the returned Promise will also be rejected with the error.
Nesting Promises
Avoid nesting Promise code. Instead, try to use flattened Promise chains.
Instead of this:
readFile(sourceFile)
.then(data => {
processData(data)
.then(result => {
writeFile(result, outputFile)
.then(() => console.log('done');
});
});
Do this:
readFile(sourceFile)
.then(data => processData(data))
.then(result => writeFile(result, outputFile))
.then(() => console.log('done'));
Summary
Here are the key points for working with Promises:
- A Promise can be pending, fulfilled, or rejected
- A Promise is settled if it is either fulfilled or rejected
- Use
then
to get the fulfilled value of a Promise - Use
catch
to handle errors - Use
finally
to perform cleanup logic that you need in either the success or error case - Chain Promises together to perform asynchronous tasks in sequence
- Use
Promise.all
to get a Promise that is fulfilled when all given Promises are fulfilled, or rejects when one of those Promises rejects - Use
Promise.allSettled
to get a Promise that is fulfilled when all given Promises are either fulfilled or rejected - Use
Promise.race
to get a Promise that is fulfilled or rejected when the first of the given Promises is either fulfilled or rejected - Use
Promise.any
to get a Promise that is fulfilled when the first of the given Promises is fulfilled - Use the
await
keyword to wait for the fulfillment value of a Promise - Use a try-catch block to handle errors when using the
await
keyword - A function that uses
await
inside of it must use theasync
keyword
Thank you for reading this deep dive on Promises. I hope you learned something new!
[ad_2]
Source link