Promise Tips: When do I need to create my own Promise instance?
Promises are pretty ubiquitous these days, but sometimes Promise
based code is more complex that it needs to be.
Consider this getUserDetails
function:
function getUserDetails(userId) {
return new Promise((resolve, reject) => {
fetch(`/users/${userId}/details`)
.then(response => response.json())
.then(data => resolve(data));
.catch(error => reject(error));
});
}
This function returns a Promise
that will be resolved when the fetch call is complete and the JSON response is received.
We make the fetch
call and extract the JSON in the first then
handler. response.json()
also returns a Promise
, so we call then
on that too. Once we have the JSON result, we can resolve our returned Promise
.
If there is an error, we reject our returned Promise
.
Here’s a somewhat rough analogy, but bear with me here. Suppose you want to buy an item listed on eBay. The above code is sort of like if you purchased the item, and the seller shipped it to eBay. Then, once eBay received it, they shipped it to you. Sounds overly complicated, right?
Client code that calls getUserDetails
looks like this:
getUserDetails(userId).then((details) => {
doSomethingWith(details);
});
We are calling then
on the outer Promise
that was returned from getUserDetails
.
We can do better! If the asynchronous thing you are doing already returns a Promise
, as fetch
does, you don’t need to wrap it in your own Promise
.
The above function can be written much more simply:
function getUserDetails(userId) {
return fetch(`/users/${userId}/details`).then((response) =>
response.json()
);
}
This function has the same net result! In both listings above, getUserDetails
returns a Promise
that resolves to the user details object.
To return to the eBay analogy: This approach is how eBay actually works: you purchase the item through eBay, eBay tells the seller, and the seller ships directly to you.
In the first example, we are creating our own Promise
which wraps the asynchronous operation. The difference with the second example is that we are just returning the Promise
that response.json()
gives us.
When would I want to create my own Promise
?
There are some cases where rolling your own Promise
is unavoidable, for example when working with a callback or event based API.
Event based APIs
Let’s make a quick and dirty image loader! This function will take an image URL and return an img
element, but not until the image has loaded.
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
resolve(image);
});
image.addEventListener('error', (error) => {
reject(error);
});
image.src = url;
});
}
Because the image element doesn’t provide a Promise
itself, in this case we have to roll our own. Depending on which event listener is fired, we either resolve the Promise
with the now fully loaded image, or reject it with the error encountered while loading.
This is easy to use:
loadImage('/logo.png').then((image) => container.appendChild(image));
Callback based APIs
Some older APIs are still callback based. You might want to make a “promisified” version of such an API. For this, you will need to create a Promise
.
Consider this simplified example of an API. It follows the typical Node.js callback pattern - it takes two callback function arguments. The first will be called if there is an error, and the second will be called on success. We use such an API like this:
db.findItem(
123,
(error) => {
console.log('oops, an error:', error);
},
(data) => {
console.log('got data:', data);
}
);
Callbacks can be a little painful to work with, so we can wrap this API with a Promise
:
function findItemPromise(recordId) {
return new Promise((resolve, reject) => {
db.findItem(
recordId,
(error) => reject(error),
(data) => resolve(data)
);
});
}
This can actually be simplified a bit more. Since the resolve
and reject
handlers take a single argument, which matches the arguments of the error
and data
callbacks, we can just specify those functions themselves as the callbacks:
function findItemPromise(recordId) {
return new Promise((resolve, reject) => {
db.findItem(recordId, reject, resolve);
});
}
Now we can use the findItemPromise
API:
findItemPromise(recordId)
.then((data) => console.log('got data:', data))
.catch((error) => console.log('oops, an error:', error));
In fact, if you are working with Node.js, you don’t need to do this wrapping yourself. The Node.js API includes a promisify
utility function that does exactly this!
Summary
- If the async API you are working with already returns a
Promise
, you most likely don’t need to wrap it in your own newPromise
. Instead, you can just utilize the existingPromise
. - For event and callback based APIs, you will need to create your own
Promise
which wraps the API call, and calls theresolve
andreject
handlers accordingly. - Node.js has a
promisify
utility function that will convert any callback-based function (that follows the Node convention of (error, success) callbacks) into aPromise
based one.