Better error handling using async/await with Axios

As a frontend developer, I've had to use an external API in almost every project. That's how most web apps are built these days: there's a REST API for your backend and it's consumed on the frontend app using tools like Axios. But there are some problems when it comes to handling errors for asynchronous code. In this post I'll explain them and show a solution that makes the code look better and simpler.

Disclaimer: In this post I won't be covering the basics of consuming an API or installing/using axios; I will focus on what the title of this post says: how to handle errors.

Handling errors using Promises

The async/await keywords were added to JS in ES2017. Before this way of doing asynchronous operations was introduced, we had to use Promises which made the code hard to read. Let's assume we have an API endpoint that returns a list of all the users in a platform:

// If the request was successful, the function will return the response,
// otherwise, it will throw an error.
const getAllUsers = () => {
return axios.get(`${API_URL}/users`).then(response => {
return response
}).catch(error => {
throw error
})
}

In order to handle errors in Promises we just had to chain a catch at the end of the Promise and we could do whatever we needed to do, so calling the function would look something like this:

const onBtnClick = () => {
getAllUsers().then(response => {
// Display the list to the user
}).catch(error => {
// Display an error message to the user
})
}

That doesn't look so bad, but in big projects where we need to do multiple operations based on the result of the Promise, the code can get really messy. This problem is especially noticeable when we have to chain multiple Promises together.

Handling errors using Async/Await

The async functions were created in order to make asynchronous code more readable and easier to understand. You can look at the code and it will look more straightforward since it's written like synchronous code:

const getAllUsers = async () => {
const response = await axios.get(`${API_URL}/users`)
return response
}

This instantly looks so much better than using Promises chained with then and catch. The asynchronous function will wait for the response of the API call and then execute the rest of the code after that. But in that example, we're not handling the error of the API call: we're just assuming the API will always return a successful response. In order to fix this, there are two approaches that are most commonly used.

1. Chaining a catch to the API call

const getAllUsers = async () => {
const response = await axios.get(`${API_URL}/users`).catch(error => error)
return response
}

The main problem of this approach is that it defeats the purpose of using async/await. We go back to chaining catch to the API call and that's not the result we want. We want the code to look simple and more like synchronous code.

When using this approach, we end up handling the error based on the response object properties, not the actual error itself:

const onBtnClick = async () => {
// This "response" constant can be either an error or a successful response
const response = await getAllUsers()
if (response.data) {
// Display the list to the user
}
else {
// Display an error message to the user
}
}

As you can see, we're doing different things depending if response has the property data. This is not what we want since it can be a little confusing: response is always defined, but it could be an error or a successful response. Also, error responses have a data property, but it is under response.response.data.

2. Using try/catch

This second approach lets us handle the actual error instead of checking if the response constant has a data property. The problem here is that we end up having to use try/catch everywhere we need to use this function and this is kind of the same problem of having to chain then to Promises. It makes the code look messy and hard to read.

The main purpose of using asynchronous functions is to make the code look better, so having to use try/catch everywhere seems like a worse problem introduced by using async/await. Using this function would look something like this:

const getAllUsers = async () => {
const response = await axios.get(`${API_URL}/users`)
return response
}
const onBtnClick = async () => {
try {
const response = await getAllUsers()
} catch (error) {
// Display an error message to the user
return
}
// Display the list to the user
}

Better error handling using async/await

The last approach I mentioned isn't bad if it's only used once in the code. It could be so much better if we could make one try/catch in the function that does the API call: getAllUsers and then write synchronous code everywhere else. This is where we can use Destructuring assignment in order to achieve the solution we want.

Using the same examples as before, we can return an object with different properties depending if the response is successful or not:

const getAllUsers = async () => {
try {
const response = await axios.get(`${API_URL}/users`)
return { response }
} catch (error) {
return { error }
}
}

This way, the function getAllUsers will always return an object, but it will have different properties based on the response of the API call. This will help us make sure that we're actually doing different actions based on the response of the API call. We just need to assign the values that are returned by the function to constants in our code:

const onBtnClick = async () => {
// One of this constants will always be undefined depending on what getAllUsers returns
// If it is successful, it will return an object with a "response" property
// If there was an error, it will return an object with an "error" property
const { response, error } = await getAllUsers()
if (response) {
// Display the list to the user
return
}
// Display an error message to the user
}

For this last approach we still have to use try/catch in our code, but only once where the API is being called. Everywhere else in our code we can just use the properties defined in the object that is returned by getAllUsers. This makes our code look like synchronous code again and it's so much easier to read.