DEV Community

loading...
Cover image for This is How [JavaScript] Promises Really Work

This is How [JavaScript] Promises Really Work

Clean Code Studio
Clean Code Clean Life ~ Simplify
・Updated on ・7 min read
cleancodestudio image

Twitter Follow

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.


JS promise state

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))
Enter fullscreen mode Exit fullscreen mode

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)
   }
})
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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) 

Enter fullscreen mode Exit fullscreen mode

"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 is OK 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)
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

Useful Packages Using and/or Simplifying Promises


cleancodestudio image

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
Clean Code Studio

Clean Code Studio - Clean Code Clean Life - Simplify!

Discussion (11)

Collapse
lukeshiru profile image
LUKESHIRU

Nice article! Love the fact that you mentioned async/await but didn't used them. Some folks might go bananas because you didn't, but I really prefer a pure promise syntax with then over the async/await counterpart. One thing worth mentioning is that you can make your promise handlers leaner by just passing them. So in cases like this:

aPromise()
  .then(data => console.log(data))
  .catch(error => console.error(error));
Enter fullscreen mode Exit fullscreen mode

You can instead just do this:

aPromise()
  .then(console.log)
  .catch(console.error);
Enter fullscreen mode Exit fullscreen mode

I would even say is more readable that way, or at least more readable than doing it with async/await 🤣...

try {
  const data = await aPromise();
  console.log(data);
} catch (error) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

Cheers!

Collapse
cleancodestudio profile image
Clean Code Studio Author

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!

Collapse
johnt_f807e8f581 profile image
JohnT

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).

Collapse
cleancodestudio profile image
Clean Code Studio Author

Thanks JohnT!

Collapse
click2install profile image
click2install

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 👍

Collapse
cleancodestudio profile image
Clean Code Studio Author

Appreciate the feed back @click2install

Collapse
asifm profile image
Asif Mehedi

It's not an easy topic to explain but you did that superbly.

Collapse
cleancodestudio profile image
Clean Code Studio Author

Thank you sir!

Collapse
cleancodestudio profile image
Clean Code Studio Author

Collapse
cleancodestudio profile image
Clean Code Studio Author