Did you know I have a newsletter? 📬
If you want to get notified when I publish new blog posts or
make major project announcements, head over to
https://cleancodestudio.paperform.co/
This is How [JavaScript] Promises Really Work
Promises are one technique to handle asynchronous code,
otherwise known as your first class ticket out of callback hell.
3 State's of a Promise
- Pending State
- Resolved State
- Rejected State
Understanding JavaScript Promises
What is a promise?
Commonly, a promise is defined as a proxy for a value that will eventually become available.
Promises have been a part of JavaScript for years (standardized and introduced in ES2015). More recently, the async
and await
keywords (introduced in ES2017) have more deeply integrated and cleaned up the syntax for promises within JavaScript.
Async functions use promises behind the scenes, thus - especially with todays distributed cloud architectures becoming more common - understanding what promises are and how they work is more important than ever!
Now that we know promises are important, let's dive in.
How Promises Work (Brief Explanation)
Your code calls a promise. This promise will start in what is known as its pending state. What's this mean?
This means that the calling function will continue executing while the promise is pending. Once the promise is resolved the calling function will get the data that was being requested by the promise.
A promise starts in a pending state and eventually ends in a resolved state or a rejected state.
Whether the final outcome be a promise in its resolved state or a promise in its rejected state, a callback will be called.
We define two separate callbacks.
One callback handles the data returned from a promise when it ends in a resolved state.
The other callback handles the data returned from a promise when it ends in a rejected state.
We define the callback function that handles the promise data that ends in a resolved state by passing our callback function to then.
We define the callback function that handles the promise data that ends in a rejected state by passing our callback function to catch.
Example using axios npm library
axios.get(endpoint)
.then(data => resolvedPromiseCallbackFunction(data))
.catch(errors => rejectedPromiseCallbackFunction(errors))
Which JavaScript APIs use promises?
Your own code and libraries will most likely use promises throughout. That being noted, promises are actually used by standard modern web APIS. Here's a couple web APIs that also use promises.
In modern JavaScript, it's pretty unlikely you'll find yourself in a situation where you're not using promises - so let's dive deep and start understanding them.
Creating promises
JavaScript has a Promise API. The Promise API exposes a promise constructor, which you initialize using new Promise()
:
let complete = true
const hasItCompleted = new Promise((resolve, reject) => {
if (complete) {
const completed = 'Here is the thing I built'
resolve(completed)
} else {
const withReason = 'Still doing something else'
reject(withReason)
}
})
As shown, we check the complete
global constant. If complete
is true, the promise switched to the resolved state (aka we call the resolve callback which switches the promise to its resolved state). Otherwise, if complete
is false, the reject
callback is executed, putting the promise into a rejected state.
Okay - easy enough, if we call the resolve
callback then our promise switches to the resolved state where as if we use the reject
callback our promise switches to its rejected state. That leaves us with a question though.
What if we call neither the resolve
nor the reject
callback? Well, as you might be putting together, then the promise remains in its pending state.
Simple enough, three states - two callback functions to switch to Resolved State or Rejected State, if we call neither callback then we simply remain in the Pending State.
Promisifying
A more common example that may cross your path is a technique known as Promisifying.
Promisifying is a way to be able to use a classic JavaScript function that takes a callback, and have it return a promise:
const fileSystem = require('fs')
const getFile = file => {
return new Promise((resolve, reject) => {
fileSystem.readFile(file, (err, data) => {
if (err) {
reject(err)
return
}
resolve(data)
})
})
}
let file = '/etc/passwd'
getFile(file)
.then(data => console.log(data))
.catch(err => console.error(err))
In recent versions of Node.js, you won't have to do this
manual conversion for a lot of the API. There is a
promisifying function available in the util module that will > do this for you, given that the function you're
promisifying has the correct signature.
Consuming A Promise
Now that understand how a promise can be created using new Promise()
as well as the Promisifying technique, let's talk about consuming a promise.
How do we use a promise (aka how do we consume a promise)
const isItDoneYet = new Promise(/* ... as above ... */)
//...
const checkIfItsDone = () => {
isItDoneYet
.then(ok => {
console.log(ok)
})
.catch(err => {
console.error(err)
})
}
Running checkIfItsDone()
will specify functions to execute when the isItDoneYet
promise resolves (in the then
call) or rejects (in the catch
call).
Fluently Chaining Promises
What if we want to call another promise directly after a previous promise is returned. We can do this, and it's simply called creating a chain of promises.
An example of chaining promises can be found within the Fetch API, which may be used to get a resource and queue (First in First out line) a chain of promises to execute when the resource is fetched.
For starters, let's first point out that the Fetch API is a promise-based mechanism. Calling the fetch()
method is equivalent to defining our own promise using new Promise()
.
Here's an example of chaining promises fluently together:
const status = response =>
response.status >= 200 && response.status < 300
? Promise.resolve(response)
: Promise.reject(new Error(response.statusText))
const json = response => response.json()
fetch('/items.json')
.then(status)
.then(json)
.then(data => console.log('Request success (with json): ', data))
.catch(error => console.log('Request failed: ', error)
"node-fetch is minimal code for window.fetch compatible API on Node.js runtime."
So, what'd we just do?
Well, in the example above we call fetch()
to get a list of items from the items.json
file found in the domain root.
Then we create a chaing of promises.
Running fetch()
returns a response.
- Response contains
status
(numeric HTTP status code) - Response contains
statusText
(string message, which isOK
if everything is successful)
response
also contains a method callable as json()
. Responses json method returns a promise that will resolve with the content of the body data processed and transformed into JSON
.
Then we have a final promise in our chain passed in as a anonymous callback function.
data => console.log('Request success (with json): ', data)
This function simply logs that we were successful and console logs the successful requests json data.
_"What if the first promise was rejected though?"
If the first promise would have been rejected, or the second promise, or the third - then, no matter the step, we're automatically going to default to the catch
callback method that is visually shown at the end of our fluent promise chain.
Handling Errors
We have a promise chain, something fails, uh oh - so what happens?
If anything in the chain of promises fails and raises an error or ultimately sets the promise's state to a Rejected Promise State, the control goes directly to the nearest catch()
statement down our promise chain.
new Promise((resolve, reject) => {
throw new Error('Error')
}).catch(err => {
console.error(err)
})
// or
new Promise((resolve, reject) => {
reject('Error')
}).catch(err => {
console.error(err)
})
Cascading errors
What if we raise an error inside a catch()
? Well, check it - we can simply append a second catch()
. The second catch()
will handle the error (or more specifically error message) and so on.
new Promise((resolve, reject) => {
throw new Error('Error')
})
.catch(err => {
throw new Error('Error')
})
.catch(err => {
console.error(err)
})
Promises Orchestration
Okay, so now we're solid when it comes to a single promise and our foundational understanding of promises in general.
Getting more advanced, let's ask another question. If you need to synchronize different promises - say pull data from multiple endpoints and handle the resolved promise data from all of the promises created and used to retrieve results from these differing endpoints - how would we do it?
How would we synchronize different promises and execute something when they are all resolved?
Answer: Promise.all()
Promise.all()
helps us define a list of promises and execute something when they are all resolved - it allows us to synchronize promises.
Promise.all()
Example:
const one = fetch('/one.json')
const two = fetch('/two.json')
Promise.all([one, two])
.then(response => console.log('Array of results: ', response)
.catch(errors => console.error(errors))
With destructuring, we can simplify this example to:
const [one, two] = [fetch('/one.json'), fetch('/two.json')]
Promise.all([one, two])
.then(([resA, resB]) => console.log('results: ', resA, resB))
Promise.race()
What if we want to get all of the data from these multiple APIs, but we really only need enough data returned from one endpoint to show on our page?
That is we need to resolve all of our promises no matter what, however we want to do something with the data from the first resolved promise and we don't care which promise is resolved first.
To handle the data from the first resolved promise we can use Promise.race()
.
Promise.race()
runs when the first of the promises you pass to it resolves, and it runs the attached callback just once, with the result of the first promise resolved.
Example
const first = new Promise((resolve, reject) => {
setTimeout(resolve, 500, 'first')
})
const second = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'second')
})
Promise.race([first, second]).then(result => {
console.log(result) // second
})
Useful Packages Using and/or Simplifying Promises
Did you know I have a newsletter? 📬
If you want to get notified when I publish new blog posts or
make major project announcements, head over to
https://cleancodestudio.paperform.co/
Clean Code Studio - Clean Code Clean Life - Simplify!
Top comments (9)
Great content and to the point. Thank you sir!
Keep these coming! Your entire JavaScript series is A1. Incredibly insightful JavaScript blog posts across the board!
10/10 recommend this entire JavaScript series for anyone reading this comment (The other posts in this js series I’m referring as also great reads are the JS posts linked to at the top and bottom of this article).
Thanks JohnT!
You mention "handling errors" but then don't handle it. try/catch/log is almost always an anti-pattern as is try/catch/throw (and other variants) and whilst I appreciate this is a contrived tutorial example I see error swallowing all too often in production code. Nice write up otherwise 👍
Appreciate the feed back @click2install
Thanks for the comment, I also love that second syntax and appreciate you adding it in.
I wasn't sure how popular it is outside of the functional js community, but dang does it look pretty.
Appreciate you taking the time to lead your insights and tips mate!
It's not an easy topic to explain but you did that superbly.
Thank you sir!