Categories
APIs ES6 JavaScript

Asynchronous Javascript: fetch, Promises and async / await

Understanding asynchronous javascript – and working with APIs generally – can really take your horizons to a whole new level. There are countless APIs out there that suddenly become available to you; whether that’s free datasets or triggering functionality in your favourite product.

The world is your lobster!

Contents

Synchronous vs Asynchronous Javascript

Synchronous code is executed line by line, where one line waits for the previous one to finish before it executes.

In the example below, the console.log() statement doesn’t run until the alert() has been dismissed.

alert("This is blocking the next line.")
console.log("The alert has been dismissed")

Asynchronous code, on the other hand, runs in the background and so is non-blocking. Only certain functions, such as setTimeout(), are asynchronous.

In the example below, we start two timers. If it were synchronous, the second timer would only start once the first timer had completed and the whole operation would last six seconds. However, this is not the case; both start at the same time and so it only lasts three seconds.

setTimeout(function(){
  console.log("First timer complete.")
}, 3000)

setTimeout(function(){
  console.log("Second timer complete.")
}, 3000)

Callback Hell

Now, say we actually wanted the asynchronous code above to work in a synchronous way. In other words, we wanted to do something only after the first timer had ended). We would have to do something like this:

setTimeout(function(){
  console.log("First timer complete.")
  setTimeout(function(){
    console.log("Second timer complete.")
  }, 3000)
}, 3000)

Relatively straightforward – you just nest them! But wait. What if you had a more complex task on your hands and you needed to do several things, one after the other. Your code would start to look something like this:

setTimeout(function() {
  console.log("First timer complete.")
  setTimeout(function() {
    console.log("Second timer complete.")
    setTimeout(function() {
      console.log("Third timer complete.")
      setTimeout(function() {
        console.log("Fourth timer complete.")
        setTimeout(function() {
          console.log("Fifth timer complete.")
        }, 3000)
      }, 3000)
    }, 3000)
  }, 3000)
}, 3000)

This is starting to look pretty horrendous.

Welcome to Callback Hell!

Callback Hell is when you are trying to chain multiple statements and the nesting gets out of control. When the nesting gets too deep, the code’s readability and manageability take a hit.

Avoiding API Callback Hell with fetch, then and Promises

Just like setTimeout(), working with APIs via XMLHttpRequest() is an asynchronous action.

The example below includes just one request, but imagine if we had to nest multiple requests in order to chain them together in a specific order. The complexity would grow and grow.

const request = new XMLHttpRequest();
request.open('GET', 'https://restcountries.com/v3.1/name/portugal');
request.send();
request.addEventListener('load', function () {
    const [data] = JSON.parse(this.responseText)
    console.log(data)
})

To solve the callback hell problem when working with external resources such as APIs, ES6 introduced the fetch() method and Promises.

How to Use fetch()

Though there’s plenty of flexibility and complexity available in using fetch(), you can issue a very basic GET request with the bare minimum of providing a URL as in the example below.

const request = fetch('https://restcountries.com/v3.1/name/portugal')

The request above returns what’s called a Promise.

What are Promises?

A Promise is a special object that is used as a placeholder for the future result of an asynchronous operation. It’s like a container for a future value (such as the response from an API call).

Promises mean we no longer need to rely on events / event listeners and callbacks to handle asynchronous results. They also allow us to chain promises together without nesting to avoid callback hell (while not avoiding callbacks entirely).

Promises have three states that you can handle accordingly:

  1. Pending – this is before the value is available (i.e. the API has responded)
  2. Settled (fulfilled) – the promise was successful
  3. Settled (rejected) – the promise ailed, such as an error occurring when using fetch()

You can build a promise manually (more on this later), and you can consume a promise such as one returned from a fetch() request.

So, how do we chain promises together and avoid callback hell in practice?

We can use the then() method.

Chaining promises with then()

This method follows a Promise (such as that which is returned by fetch()) and it returns a Promise, too.

So, expanding upon the fetch() example above, we can make it log exactly the same response as the XMLHttpRequest() example we started with. The benefits are that it’s in significantly fewer lines, less complexity and can be easily chained further.

fetch('https://restcountries.com/v3.1/name/portugal')
  .then(response => response.json())
  .then(data => console.log(data[0]))

As you can see, we can pass a callback function to the method that is executed as soon as the previous promise is fulfilled (such as getting the data back from an API call).

Note: In the example above, we call the json() method on the response to get to the data itself from the response body, however it will also return a new promise so we need to use then() again to handle that response.

The first parameter / argument to that function is onFulfilled. If we added a second, it would be for onRejected.

We can use that second parameter as one way to handle errors.

Error Handling in Promises

We can handle errors in a few ways.

then()‘s onRejected parameter

The first is by providing that second callback function to the then() method (the onRejected parameter we mentioned in the section above).

Unfortunately, we can’t really use fetch to demonstrate this as it will always return a fulfilled promise (unless you have no internet connection, that is). This isn’t particularly helpful as we want to know if things haven’t gone to plan beyond just our wifi dropping out. We want to know if our API requests was invalid, or if the API is down, for example. So, we’ll use a manually-created Promise instead (which will be explained in more detail later on) and force it to be rejected.

const promise1 = new Promise((resolve, reject) => {
  reject('An error occurred')
})

promise1
  .then(
    success => {
      console.log(`Success: ${success}`)
    },
    error => {
      console.log(`Error: ${error}`)
    }
  )

catch()

The other option is to handle all errors at the end of the chain, regardless of where they occurred, with a catch() statement.

const promise1 = new Promise((resolve, reject) => {
  reject('An error occurred')
})

promise1
  .then(response => console.log(`Success: ${response}`))
  .catch(err => console.log(`Error: ${err}`))

It’s worth noting that catch() also returns a promise. We can run the finally() method after the catch() to wrap things up if we need to.

In the two examples above, we purposefully avoided using fetch() to generate the promise. But what if we needed to use it? How could we catch errors if a fulfilled promise would always be returned?

Throwing errors manually with throw new Error()

Going back to our fetch() example, if we were to make use of the second error handling option above (catch()) then the code would look like this.

fetch('https://restcountries.com/v3.1/name/ABCDEFG')
  .then(response => console.log(response))
  .then(data => console.log(data))
  .catch(err => console.log(`Error: ${err}`))

Considering there isn’t a country called “ABCDEFG”, as far as I’m aware, we would expect an error to be thrown. However, the code above actually prints to the console the response – not an error.

Digging into the response, you can see that the ok property is set to false, the status property is set to 404 and the statusText is "Not Found". So, we need to check for that in our code and trigger an error.

We can do this with throw new Error(). Here’s an example:

fetch('https://restcountries.com/v3.1/name/ABCDEFG')
  .then(response => {
  	if (!response.ok)
  		throw new Error(`Country not found (${response.status})`)
    console.log(response)
  })
  .then(data => console.log(data))
  .catch(err => console.log(`Error: ${err}`))

By doing this, we immediately terminate the current function, the promise will reject and thus it will propagate down to the catch() method at the end which is designed to pick up these errors.

It’s often a good idea to show informative error information to users on the front-end to give them a good idea of what went wrong.

Brief Intro to the Event Loop

Javascript’s event loop essentially handles the queuing of tasks and the calling of them into the stack. Once the call stack is empty, it takes the next (oldest) queued task from the callback queue. This is how it runs each statement (in a grossly oversimplified way).

Because javascript is single-threaded and messages are dealt with one by one in a synchronous nature, we have to be conscious of that fact that this can impact timing of when functions are run. For instance, if our callback queue is filled with heavy duty tasks and we add on a 5-second setTimeout() timer, then we can be fairly certain that we’ll be waiting longer than five seconds for the code within that timer to run.

Promises, however, can take a shortcut via the microtasks queue. The tasks in this queue get priority.

Building Promises

Promises are a special type of object and can be manually created with a constructor new Promise() (just like we did with our Error() earlier).

It takes one argument – an executor function – and that function, in turn, takes two arguments; resolve and reject. We saw these in our first couple of error handling examples above where we forced the promise to be rejected. They do what they say;

  • resolve() marks the promise as fulfilled
  • reject() lets you return an error

In this example, simulate a random lottery where you either win (resolve) or lose (reject).

const lotteryPromise = new Promise(function(resolve, reject) {
  console.log("Lottery draw is happening")
  setTimeout(function() {
    if (Math.random() >= 0.5) {
      resolve('You WIN')
    } else {
      reject(new Error('You LOSE'))
    }
  }, 2000)
})

lotteryPromise
  .then(res => console.log(res))
  .catch(err => console.error(err))

In some situations you can simplify how you resolve and reject built Promises. Take the navigator.geolocation.getCurrentPosition() method as an example. It takes two arguments, just like a Promise does – success and error – which represent callback functions. These match up nicely with resolve and reject, so you can do something like this:

const getPosition = function() {
  return new Promise(function(resolve, reject) {
    navigator.geolocation.getCurrentPosition(resolve, reject)
  })
}

getPosition()
  .then(pos => console.log(pos))

You can, however, avoid manually creating Promises and chaining then() by converting regular synchronous functions to be asynchronous.

async / await

By using the async keyword just before defining a function(), we turn it into an asynchronous function that returns a promise.

Inside of the now-asynchronous function, we can have one or more statements that use the await operator and return a promise that’s stored in a variable.

const getPopulation = async function(country) {
  const response = await fetch(`https://restcountries.com/v3.1/name/${country}`)
  const data = await response.json()
  console.log(`${(+data[0].population / 1000000).toFixed(1)} million people live in ${data[0].name.common}`)
}

getPopulation('usa')

This is potentially an easier way to consume promises as it removes the need for multiple then() callback functions (async functions are actually just syntactic sugar on top of then()).

try...catch

In the example above, you’ll notice that we don’t have any error handling. For async functions, we can use a try...catch statement to manage it.

If an error occurs in the try block then the code in the catch block will be executed. This allows us to catch and handle errors before they reach the console and cause the script to stop running.

Inside of the async function, we wrap all of the code we want to execute in the try statement (including all of the await expressions) and follow it up with the catch(error) block.

const getPopulation = async function(country) {
  try {
    const response = await fetch(`https://restcountries.com/v3.1/name/${country}`)
    const data = await response.json()
    if (!data.ok) throw new Error('Problem getting country data.')
  } catch (error) {
    console.error(`Something went wrong! ${error.message}`)
  }
}

getPopulation('notacountry')

Running Promises in Parallel

So far we’ve been working with asynchronous functions and promises in a synchronous way – waiting for a promise to resolve before using the returned data to do something else – but sometimes we’ll want to run them in parallel. Fortunately, there’s a few Promise methods that help us here.

Promise.all()

Promise.all() takes an array of promises, will return a new promise and run all of the promises at the same time.

If one promise rejects, the method will short circuit and throw an error. All promises need to resolve in order for this to run successfully.

const check3Countries = async function(c1, c2, c3) {
  try {
    const data = await Promise.all([
      fetch(`https://restcountries.com/v3.1/name/${c1}`),
      fetch(`https://restcountries.com/v3.1/name/${c2}`),
      fetch(`https://restcountries.com/v3.1/name/${c3}`)
    ])
    console.log(data.map(d => d.ok))
  } catch (err) {
    console.error(err)
  }
}

check3Countries('portugal', 'canada', 'notacountry')

Promise.race()

Promise.race() also accepts an array of promises, but this time it settles as soon as any one of the promises is fulfilled; whether it’s rejected or resolved.

(async function() {
  const res = await Promise.race([
    fetch(`https://restcountries.com/v2/name/italy`),
    fetch(`https://restcountries.com/v2/name/mexico`),
    fetch(`https://restcountries.com/v2/name/egypt`)
  ])
  console.log(`URL: ${res.url}, Status: ${res.status}`)
})()

It can be used to good effect as a form of timeout function to check if an API call is taking too long, for instance.

const timeout = function(sec) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      reject(new Error('Request took too long!'))
    }, sec * 1000)
  })
}

Promise.race([
    fetch(`https://restcountries.com/v3.1/name/italy`),
    timeout(0.05)
  ])
  .then(res => res.json())
  .then(data => console.log(data[0]))
  .catch(err => console.error(err))

Promise.allSettled()

Promise.allSettled() returns an array of all the promises once they’ve settled, regardless of whether they’ve been resolved or rejected.

It’s similar to Promises.all(), but this method will not short-circuit on a rejection.

Promise.allSettled([
  Promise.resolve('Success'),
  Promise.reject('Error'),
  Promise.resolve('Success')
]).then(res => console.log(res))

Promise.any()

Finally, Promise.any() will return the first fulfilled promise and ignore any rejected promises unless all of the provided promises are.

Promise.any([
  Promise.resolve('Success'),
  Promise.reject('Error'),
  Promise.resolve('Success')
]).then(res => console.log(res))

Leave a Reply

Your email address will not be published.