6. Async/Await

circle-info

Follow along with code examples herearrow-up-right!

Table of Contents:

Key Concepts

  • async function — a function that always returns a Promise. Lets you use await inside it.

  • await — used inside an async function; pauses until the Promise resolves, then gives you the resolved value. Makes async code look like synchronous code.

  • try / catch — run code in try; if any of it throws (including from await), execution jumps to catch so you can handle the error instead of crashing.

Intro: Promises Are Tricky

Take a look at this code in src/fetch-helpers.js. What's missing from with this code? Why does it produce the error "Cannot read properties of undefined (reading 'map')"?

chevron-rightAnswerhashtag

We forgot to return promiseToExtractData from the first .then callback. This means that the first .then() returns a promise that resolves to undefined in the second .then() callback.

So many .then() calls

When fetching, it is important to observe that we have two sequential asynchronous processes involved, each with their own promise and each with their own .then():

  • fetch() returns a promise handled by the first .then()

    • When fetch() resolves, we start extracting data with response.json() which returns another promise handled by the second .then().

      • When response.json() resolves, we do some data manipulation and return the formatted data. This is the final return statement.

We have to also remember that the entire getUsers() function is performing these asynchronous operations so it is also inherently asynchronous. When we return from getUsers(), we return a promise that the main code must resolve. So many promises to resolve! So many .then() calls!

There must be an easier way!

Async / Await

Working with callbacks can be conceptually difficult to follow at times. So, an alternate syntax was created to achieve the same effect but without the need for callbacks.

This approach utilizes the async and await keywords:

  • The await keyword is placed before any expression that returns a Promise

    • It causes our code to pause and wait for the Promise to resolve.

    • It then "unpacks" the Promise and returns the resolved value.

  • The async keyword is placed before any function that uses await inside:

    • Any value returned by this function now will be wrapped in a Promise

What about Errors? Try and Catch

This entire process is very error prone in ways that we can't avoid:

  • fetch() throws an error if the given URL is invalid or your network is down.

  • response.json() throws an error if the response data is not in JSON format

  • We manually throw our own error using throw new Error() when the fetch fails due to a 4xx or 5xx response:

All of these errors crash the program, which we want to avoid.

try/catch is the standard way to handle those errors and it will prevent the application from crashing:

Note with either syntax, our main code doesn't need to catch anything. Our main code simply checks if the data is present or if it was null in which case we can render an error message to the screen.

The benefits of async/await

Using the async/await syntax with try and catch has a number of benefits. The main ones are readability and debuggability.

  • We can write async code in a synchronous-like manner

  • We avoid having to write a bunch of callbacks

  • We can avoid common mistakes made when using callbacks

  • try/catch is a more general-purpose way of handling errors that can be used for more than just fetching.

Best Practice: Standardize the Return Structure

There is still one way to improve getUsers().

We are currently telling the caller of the function (the main code) that an error occurred by returning null:

What if we wanted to return the error itself? This could be useful if we want our main code to display the exact error message to the user.

One common solution to this is to always return an object with data and error properties:

  • If the fetch succeeds, error will be null: { data: users, error: null }

  • If the fetch fails, data will be null: { data: null, error: error }

This allows the main code to check the .data and .error properties to determine what to do next:

chevron-rightQ: Why can't we simply return the users if the fetch succeeds and the error if it fails?hashtag

There won't be an easy way to differentiate the two if result can be either a users array or an error object.

In both cases, we have a non-null value so if (result === null) checks won't work.

We could do something like if (result instanceof Error) to determine if the value is an Error object but it is entirely possible that the API we are fetching from has data in an error-like object.

This strategy avoids any ambiguity. It ensures that the data property contains the data we're fetching and the error property contains an error if one occurred.

Best Practice: Fetch Helper Functions

Since most of this code is "boilerplate", it is a good practice to create a helper function that handles most of this fetching logic for us:

We can also make helpers to create config objects for us:

We can use these functions like this to really simplify the process of writing functions for specific endpoints / HTTP methods:

Last updated