DEV Community

Gohomewho
Gohomewho

Posted on

Promise and async await

Probably most of us learn Promise for the first time is when we start learning how to call an API with fetch to get recourses on the web. They are two complex topics. It is difficult to learn them at once. In this article, we will learn how to use Promise and its syntax sugar async await. So later when you learn fetch, you don't need to worry about Promise.

By the way, if you don't know what API means or you have been confused by the term, I like to think it as anything that people make it easy for us to use. In web, people usually refers API to an URL that you can simply call it, and it will give you data or do some stuff on the server side. API can mean different things in different context. Promise is a browser API, which means that it is not a built in feature in JavaScript. If we run JavaScript in Browsers, we can easily use those browser API, because people have made them easy for us.

If you'd like to follow along, you can open the console of devtools and code right there. It would be better to open the console on a new browser tab, because sometimes there are errors that you need to refresh to get rid of, but you don't want to refresh the page you currently reading.


Understand how to use promise

I think there are two keys to understand how to use Promise, callback and the order of code execution.

Callback

Callback is essentially a function, it is passed to another function as an argument. How callback is called and used is decided inside the function that we pass the callback to. Let's look at an example.

// make a function that accept a callback
function myFunc(callback) {
  const state = {
    status: 'pending',
    value: undefined
  } 

  // callback is just a variable name that you can name it whatever you like
  // but since we call it like a function, it has to be a function
  // here, we just simply call it
  callback()

  return state 
}
Enter fullscreen mode Exit fullscreen mode

Calling myFunc without passing a function as an argument. That callback parameter inside myFunc will be undefined, so this will error.

myFunc() 
// Uncaught TypeError: callback is not a function
Enter fullscreen mode Exit fullscreen mode

Passing a function as a callback to myFunc. We can define inline function as an argument directly when we are calling a function.

myFunc(() => {
  console.log('hello')
})
// hello
// {status: 'pending', value: undefined}
Enter fullscreen mode Exit fullscreen mode

'hello' is logged by the callback and the object is returned by myFunc.

Generally when you see a function that accept a callback, that callback will be called with some variables that lives inside the function.

function myFunc(callback) {
  const state = {
    status: 'pending',
    value: undefined
  } 

  // add a function
  function resolve() {
    state.status = 'fulfilled'
  }

  // add another function
  function reject() {
    state.status = 'rejected'
  }

  // pass new added function as arguments
  callback(resolve, reject)

  return state
}
Enter fullscreen mode Exit fullscreen mode

Now our callback is called with two arguments, so we can define the callback with two parameters.

const result = myFunc((res, rej) => {})
result // {status: 'pending', value: undefined}
Enter fullscreen mode Exit fullscreen mode

I name the parameters of the callback res and rej on purpose. We can name parameters whatever we want. But if we don't call the callback in myFunc with those two arguments resolve and reject, then res and rej will be undefined. Just like what we saw earlier why we call myFunc() without passing a callback will show error. res and rej do not magically appear. It can be confused because normally we would define a function before we use it. On the other hand, callback is like it is used before we define it. Because it is already used somehow and somewhere inside a function, we'll need to follow the rules.

We can call the resolve function passed from myFunc in our callback.

const result = myFunc((res, rej) => {
  res() // res is the resolve function passed from myFunc
})
// status becomes 'fulfilled'
result // {status: 'fulfilled', value: undefined}
Enter fullscreen mode Exit fullscreen mode

Modify resolve and reject function so they can take value and change the state.

function myFunc(callback) {
  const state = {
    status: 'pending',
    value: undefined
  } 

  // accept a parameter
  function resolve(v) {
    state.status = 'fulfilled'
    // modify state.value with that parameter
    state.value = v
  }

  // add a parameter
  function reject(v) {
    state.status = 'rejected'
    // modify state.value with that parameter
    state.value = v
  }

  callback(resolve, reject)

  return state
}
Enter fullscreen mode Exit fullscreen mode

Calling resolve with a value in the callback.

// we only want to call resolve
// so we can omit defining a second parameter on the callback
const result = myFunc((res) => {
  res('nice')
})
result // {status: 'fulfilled', value: 'nice'}
Enter fullscreen mode Exit fullscreen mode

Calling reject with a value in the callback.

const result = myFunc((res, rej) => {
  rej('bad')
})
result // {status: 'rejected', value: 'bad'}
Enter fullscreen mode Exit fullscreen mode

We can use a condition to determine whether we call resolve or reject.

const result = myFunc((res, rej) => {
  // a lots of logic
  // ...
  const someCondition = true // imagine it could be true or false

  if(someCondition === true) {
    res('nice')
  } else {
    rej('bad')
  }
})
Enter fullscreen mode Exit fullscreen mode

Generally, a function that accepts callbacks is more generic, and the callbacks are for us to control the state that created from the function. In our example, myFunc accept a callback and call that callback with two functions resolve and reject. In that callback, we can do some computation and use resolve and reject as we want to modify the state created from myFunc.

We could also call res and rej "callbacks". Personally, I often get confused by the word, so I just call them "functions" and focus on the code rather than a term.

Now we know how things get passed around with callback, we can move on to the next part.

The order of code execution

JavaScript runs synchronous code immediately. Synchronous code can schedule something to run asynchronously. When an asynchronous task is done and ready to do something else, it will be run after the current synchronous code is finished.

What is synchronous code

Synchronous code are run one by one. console.log() is synchronous code, we run it and it finishes instantly.

console.log(1) // run after previous code
console.log(2) // run after console.log(1)
console.log(3) // run after console.log(2)
// 1
// 2
// 3
Enter fullscreen mode Exit fullscreen mode

What is asynchronous code

setTimeout() is a browser API that can schedule something to run asychronously. In this example, setTimeout() is called immediately after console.log(1) and right before the console.log(3). The callback we pass to setTimeout(), however, is run asynchronously. That's why we'll see 3 being logged before 2.

console.log(1) // run after previous code
// setTimeout is run immediately after console.log(1)
setTimeout(() => {
  console.log(2) // the callback is scheduled to run asynchronously after 1000ms   
}, 1000)
console.log(3) // run after setTimeout
// 1
// 3 
// 2
Enter fullscreen mode Exit fullscreen mode

The second argument of setTimout() schedules how much time to run the callback later. 1000ms doesn't guarantee to be exactly 1000ms. It has to wait current synchronous code to be finished. For example, if the timer is count to 999ms, and there is an user interaction that triggers a for loop that takes 500ms to run, the callback of the setTimout() will need to wait that for loop, asynchronous code won't interrupt the order of current synchronous code.

Asynchronous code are always run after current synchronous code. Although we set the second argument of setTimeout() to 0ms, the callback of setTimeout() is still scheduled as an asynchronous code, it has to wait current synchronous code to be finished.

console.log(1)
setTimeout(() => {
  console.log(2) 
}, 0) // set delay to 0ms
console.log(3) 
// 1
// 3 
// 2
Enter fullscreen mode Exit fullscreen mode

We can imagine that JavaScript has a list of current synchronous code waiting to be executed. When asynchronous code is ready, it will be added at the end of that list and becomes part of the synchronous code.

If you are interested in learning more about how JavaScript run our code, I would recommend watching this series on YouTube created by Akshay Saini


Promise

Promise is a way to schedule asynchronous code. Unlike setTimeout that only schedules asynchronous code to run later. We can run synchronous code when we create a Promise and schedule other things to run asynchronously. Let's first learn how promise work on its own, and then we'll learn how it works with other code.

How promise works on its own

This is how we create a Promise. new Promise() with a callback () => {}. new is a JavaScript keyword used to create a new object. We can create an array like this new Array(), but usually we simply write the literal form []. Promise doesn't have a literal form, so we need to create one like new Promise(callback).

new Promise(() => {})
// Promise {<pending>}
Enter fullscreen mode Exit fullscreen mode

Notice that there is an callback that does nothing. If we don't provide a callback to new Promise(), it will error. If you don't know why, you probably should go back to previous section where we break down callback.

Inside Promise, it calls our callback with two arguments, resolve and reject. They are two functions provided from the promise to our callback. They are used to control the state of Promise.

new Promise((resolve, reject) => {})
// Promise {<pending>}
Enter fullscreen mode Exit fullscreen mode

When we call resolve(), the state of the promise becomes "fulfilled".

new Promise((resolve, reject) => {
  resolve()
})
// Promise {<fulfilled>: undefined}
Enter fullscreen mode Exit fullscreen mode

We didn't pass argument to resolve(), so the result of this promise is undefined. it's like when we call a function that doesn't not explicitly return anything, the function will always return undefined.

This time we resolve() the promise with a value 'nice'. The state of the promise becomes "fulfilled" and the result is "nice".

new Promise((resolve, reject) => {
  resolve('nice')
})
// Promise {<fulfilled>: 'nice'}
Enter fullscreen mode Exit fullscreen mode

A promise could be resolved or rejected. reject works similar to resolve, it changes the state of the promise and provides a result.

new Promise((resolve, reject) => {
  reject('bad')
})
// Promise {<rejected>: 'bad'}
Enter fullscreen mode Exit fullscreen mode

If you run this code, you will see Uncaught (in promise) bad. We'll deal with it later, so let's ignore it for now.

At this point, we only make synchronous code.

console.log(1)
new Promise((resolve, reject) => {
  console.log(2)
  resolve('nice')
  console.log(3)
})
console.log(4)

// this result shows that everything runs in order, synchronously.
// 1
// 2
// 3
// 4
Enter fullscreen mode Exit fullscreen mode

Keep one thing in mind, when we construct a promise, it schedules something to run asynchronously immediately. The action of starting a task is synchronous. It is the action of handling the result of resolve or reject being asynchronous.

So, how do we handle the result?
Promise object has a .then method that we can use to handle the result of a "fulfilled" state promise which has been resolved with resolve().

.then() also accept a callback, it will pass the result of the previous Promise to the callback.

new Promise((resolve, reject) => {
  resolve('nice')
}).then((result) => {
  console.log(result)
})

// nice
Enter fullscreen mode Exit fullscreen mode

If you run this code in the console, you probably have noticed one thing. After "nice" is logged, there is a promise logged.

// Promise {<fulfilled>: undefined}
Enter fullscreen mode Exit fullscreen mode

Before we learn where this promise comes from, we need to know why it is logged.

When we call a function in the console, it will automatically log the result of the function. If a function doesn't explicitly return anything, it returns undefined.

console.log(1)
// 1 
// console.log() doesn't explicitly return anything, so it returns undefined.
// undefined 
Enter fullscreen mode Exit fullscreen mode

I am using chrome and I am not sure if this happens in other browsers.

Alright, so where does that promise come from? In fact, what we return from .then() will be a new "fulfilled" state promise with the result of the returned value. We only console.log(result) in .then() and didn't return anything explicitly, so the result is undefined.

new Promise((resolve, reject) => {
  resolve('nice')
}).then((result) => {
  console.log(result)
})

// nice
// Promise {<fulfilled>: undefined}
Enter fullscreen mode Exit fullscreen mode

This means that we can chain .then on and on.

new Promise((resolve, reject) => {
  resolve('nice')
}).then((result) => {
  console.log(result) // 'nice'
}).then((result) => {
  console.log(result) // undefined
})
Enter fullscreen mode Exit fullscreen mode

We can use .catch() to handle the result of a "rejected" state promise which has been rejected with reject(). We can schedule something to run like promise1.then().then().catch(). if one of the promises in the chain is rejected, .then() will be skipped and .catch() will take the rejected result.

new Promise((resolve, reject) => {
  reject('bad')
}).then((result) => {
  // previous promise is rejected, so the code inside .then() is skipped
  console.log(result)
}).catch((error) => {
  console.log(error) // bad is logged from here
})

// bad 
Enter fullscreen mode Exit fullscreen mode

We handle the rejected promise, so we won't see Uncaught (in promise) bad error this time. You probably have noticed Promise {<fulfilled>: undefined} appear in the console again. This is something you'd like to keep in mind. What we return from .catch() is also a "fulfilled" state promise.

We can return a rejected promise in .then() or .catch() with throw new Error('some message').

new Promise((resolve, reject) => {
  resolve('nice')
}).then((result) => {
  if(result !== 'great') {
    // throw error inside .then will return a rejected promise
    throw new Error('not good enough') 
  }
}).catch((error) => {
  console.log(error) 
})

// Error: not good enough
Enter fullscreen mode Exit fullscreen mode

Throwing an error inside .catch().

new Promise((resolve, reject) => {
  reject('bad')
}).then((result) => {
  console.log(result) 
}).catch((error) => {
  if(error === 'bad') {
    throw new Error('this is really bad')
  }
})

// Uncaught (in promise) Error: this is really bad
Enter fullscreen mode Exit fullscreen mode

The error we see in the console proves that we returns a rejected promise.

Promise.reject() is an another way to return a rejected promise. This is useful when we want to return a more complex data like an object.

// return a rejected promise in .then
new Promise((resolve, reject) => {
  resolve('nice')
}).then((result) => {
  return Promise.reject({reason: 'something went wrong'})
})
// Promise {<rejected>: {…}}

// return a rejected promise in .catch
new Promise((resolve, reject) => {
  reject('bad')
}).catch((error) => {
  return Promise.reject({reason: 'something went wrong'})
})
// Promise {<rejected>: {…}}
Enter fullscreen mode Exit fullscreen mode

Notice that we didn't add new keyword to Promise.reject(). It is a method available on the Promise object. It's similar to how we call Array.isArray(), using the Array object.

Returning another rejected promise from .catch() seems to be redundant. The idea is that sometimes we would handle in different ways. Returning a rejected promise means that we can .catch() it in other places.

How promise work with other code

So far we only make Promise and resolve or reject it immediately. In real world, we usually don't know how much time doing something can take, but we want to react to it as soon as something is done. That's the value of scheduling asynchronous code with Promise. Let's see an example to help understand how asynchronous code run.

// define a variable 
let promiseResolver = null

new Promise((resolve, reject) => {
  // store the resolve of this promise to the variable
  promiseResolver = resolve
}).then((result) => {
  console.log(1, result)
}).then((result) => {
  console.log(2, result)
})

// This is the first promise we created with new Promise(callback)
// Promise {<pending>}
Enter fullscreen mode Exit fullscreen mode

We don't see 1 and 2 being logged from those two .then() because .then() is only called when the previous promise is resolved.

Now we can call the resolve function stored in promiseResolver to resolve the first promise.

promiseResolver('hello')
// 1 'hello'
// 2 undefined
Enter fullscreen mode Exit fullscreen mode

As soon as we call promiseResolver('hello'), first .then() can run and 1 is logged as expect. second .then() also runs because the first .then() implicitly return a "fulfilled" promise with a value undefined. That's why the second log is 2 undefined. Although we see the results from .then() right after we resolve the first promise, asynchronous code has to wait the current synchronous code to be finished, which means that if a promise is resolved or rejected during some synchronous code running, .then() or .catch() will be queued and run after those synchronous code already in the list are finished.

A promise can only be "fulfilled" or "rejected" once. Let's modify the example and explicitly return a new promise in the first .then().

let promiseResolver = null

new Promise((resolve, reject) => {
  promiseResolver = resolve
}).then((result) => {
  console.log(1, result)
  // explicitly return a new promise
  return new Promise(() => {})
}).then((result) => {
  console.log(2, result)
})
Enter fullscreen mode Exit fullscreen mode

promiseResolver only stores the resolve function of the first promise, so calling it multiple times will only work for the first time.

// resolve first promise
promiseResolver('hello')
// 1 'hello'

// no effect
promiseResolver('hello')
promiseResolver('hello')
promiseResolver('hello')
Enter fullscreen mode Exit fullscreen mode

The second .then() will never be triggered because we didn't store the resolve function of the promise returned from the first promise, which means that the state of the promise will always stay "pending".

This time we also store the resolve function of the promise returned from first .then().

let promiseResolver = null

new Promise((resolve, reject) => {
  // store resolve function of this promise
  promiseResolver = resolve
})
.then((result) => {
  console.log(1, result)
  // explicitly return a new promise 
  return new Promise((resolve, reject) => {
    // store resolve function of this promise
    promiseResolver = resolve
  })
})
.then((result) => {
  console.log(2, result)
})
Enter fullscreen mode Exit fullscreen mode

Resolving the first and the second promise with the resolve functions that were assigned to promiseResolver.

// resolve first promise created by new Promise()
promiseResolver('hello')
// run first .then
// 1 'hello' 

// resolve second promise returned by first .then
promiseResolver('hi')
// run second .then
// 2 'hi'
Enter fullscreen mode Exit fullscreen mode

Again, .then only runs after the previous promise is resolved.

Let's see how promise work with more synchronous code.

console.log(1)
new Promise((resolve, reject) => {
  console.log(2)
  resolve()
  console.log(3)
}).then(() => {
  console.log(4) // 4 is here
})
console.log(5)

// 1
// 2
// 3
// 5
// 4 is here
Enter fullscreen mode Exit fullscreen mode

This is a promise that resolves immediately. The code inside new Promise()'s callback is synchronous. Promise can schedule asynchronous code with .then(). We want to console.log(4) after the promise is resolved as soon as possible, but asynchronous code has to wait until all synchronous code are finished. When JavaScript see asynchronous code, it will put asynchronous code aside and read next statement. if next statement is synchronous, JavaScript runs that code immediately. if next statement is asynchronous, JavaScript puts that code aside again and read the next statement. When asynchronous code is ready to be run, it will be queued to the end of the synchronous code. That's why 4 is logged after 5.

Challenge:

Can you reason about the result of this snippet?

console.log(1)

new Promise((resolve, reject) => {
  console.log(2)
  resolve()  
}).then(() => {
  console.log(3) 
})

new Promise((resolve, reject) => {
  resolve()
  console.log(4)
}).then(() => {
  console.log(5) 
}).then(() => {
  console.log(6) 
})

new Promise((resolve, reject) => {
  console.log(7)
  resolve()  
}).then(() => {
  console.log(8) 
})

console.log(9)
Enter fullscreen mode Exit fullscreen mode

Result:

1
2
4
7
9
3
5
8
6
Enter fullscreen mode Exit fullscreen mode

It's totally fine if you don't get the correct result by reading the snippet, but I think you are able to tell why after you see the result. We won't use promises like this snippet in real world. This is only for testing if you understand the order of synchronous and asynchronous code.


async await

async function and await is the syntax sugar to work with Promise.

Creating an async function is easy. Define a function and put the async keyword before the function keyword. What we return from a async function will implicitly be a "fulfilled" promise.

async function myFunc() {

}
myFunc()
// Promise {<fulfilled>: undefined}
Enter fullscreen mode Exit fullscreen mode

We can explicitly throw new Error or return Promise.reject() to return a rejected promise.

async function myFunc() {
  return Promise.reject({reason: 'something went wrong'})
}
myFunc()
// Promise {<rejected>: {…}}
Enter fullscreen mode Exit fullscreen mode

The benefit of using async function is that we can use await keyword to make asynchronous code look like synchronous code.

async function myFunc() {
  console.log(1)
  // await will wait this promise to be done before moving on to next statement.
  await new Promise((resolve, reject) => {
    console.log(2)
    resolve()
  })
  // below code runs after the promise being await is done
  console.log(3)
}

myFunc()
// 1
// 2
// 3
Enter fullscreen mode Exit fullscreen mode

This is the same code writing without async and await. We need to chain a .then() to get the correct order of running console.log(3).

function myFunc() {
  console.log(1)
  new Promise((resolve, reject) => {
    console.log(2)
    resolve()
  }).then(() => {
    console.log(3)  
  })
}

myFunc()
Enter fullscreen mode Exit fullscreen mode

We can await a promise to store the result of a "fulfilled" promise with a variable

async function myFunc() {
  const result = await new Promise((resolve, reject) => {
    resolve('hello')
  })
  console.log(result)
}

myFunc()
// hello
Enter fullscreen mode Exit fullscreen mode

async function returns a promise, so it can also be await. Unless we explicitly return something or the code reach the end of a async function, the promise state would be "pending", which means that calling an async function with await, the code of that async function has to be done before moving on.

// async function returns a promise
async function myFunc() {
  console.log(1)
  await new Promise((resolve, reject) => {
    console.log(2)
    // delay the resolve
    setTimeout(() => {
      resolve()
    }, 1000)   
  })
  console.log(3)
}

// calling another async function inside this async function
async function anotherFunc() {
  console.log(4)
  // await an async function
  await myFunc()
  console.log(5)
}

anotherFunc()
// 4
/*
  await myFunc()
  1
  2 
  wait 1000ms
  3           
*/
// 5
Enter fullscreen mode Exit fullscreen mode

We can use try catch to handle rejected promise inside async function.

async function myFunc() {
  try {
    await new Promise((resolve, reject) => {
      reject('something went wrong')
    })
  } catch(error) {
    console.log(error)
  }
}

myFunc()
// something went wrong
Enter fullscreen mode Exit fullscreen mode

try catch is not a special thing for async function. It is meant to be used to catch errors.

We can handle error in the child scope and return a "fulfilled" promise, and decide what to do with the result inside the parent scope.

async function myFunc() {
  try {
    await new Promise((resolve, reject) => {
      // change the rejected value here to see different result from calling anotherFunc()
      reject('not a big deal')
    })
  } catch(error) {
    if (error === 'not a big deal') {
      return 'ok'
    } else {
      return 'bad'
    }
  }
}

async function anotherFunc() {
  try {
    // the function being called is the child scope
    const result = await myFunc()
    if (result === 'ok') {
      console.log('keep running code below')
    } else {
      throw new Error('throw error in try will jump to catch')
    }
    // other code
    // ...
  } catch(error) {
    console.log(error)
  }
}

anotherFunc()
// keep running code below
Enter fullscreen mode Exit fullscreen mode

We can also return a rejected promise from the child scope, so we don't need to examine the result again in try block of the parent scope and can handle the error directly in catch block.

async function myFunc() {
  try {
    await new Promise((resolve, reject) => {
      reject('is a big deal')
    })
  } catch(error) {
    if (error === 'not a big deal') {
      return 'ok'
    } else {
      // explicitly return a rejected promise
      return Promise.reject('bad')
    }
  }
}

async function anotherFunc() {
  try {
    // await a promise that is rejected will jump to catch
    const result = await myFunc()

    // there is an error above 
    // so rest of the code in try block won't run
    // ...
  } catch(error) {
    console.log(error)
  }
}

anotherFunc()
Enter fullscreen mode Exit fullscreen mode

Which one should be used is based on your needs.


Wrap up

In this article, we learn the necessary fundamentals to understand Promise. We learn how to use Promise and also learn how to use async await to work with Promise. I've tried to make this article as simple as possible, but learning promise can be frustrated. I hope this article can help make your journeys a little bit easier.

Latest comments (1)

Collapse
 
karim_muhammad profile image
Karim Muhammad

Bro, You Are Awesome, Really Awesome!
My night questions are here!
It's so clear, detailed explaining.