Core to working in Angular, of course, is having a good understanding of JavaScript. JavaScript is a single-threaded synchronous language that executes code in the defined order. It has to finish processing one line of code before it gets to the next one.
Browsers provide a web API to initialize asynchronous requests. For example, if we want to send a request to a server, we can use XMLHttpRequest object or Fetch API. After an asynchronous request is completed, we need to handle a successful or a failed response. In the past, jQuery was heavily used for making AJAX calls, so I will use it for examples with callbacks. Below you can see code for fetching a list of people from the swapi
API.
import $ from "jquery";
function onSuccess(data, textStatus, jqXHR) {
console.log("People fetched successfully!", data);
}
function onError(jqXHR, textStatus, errorThrown) {
console.error("There was a problem while fetching the data");
}
function onComplete(jqXHR, textStatus) {
console.log("Request completed");
}
function get(url, onSuccess, onError, onComplete) {
$.ajax(url, {
method: "GET",
success: onSuccess,
error: onError,
complete: onComplete
});
}
get("https://swapi.co/api/people", onSuccess, onError, onComplete);
Back in the day, JavaScript did not have as many features as it does now, and callbacks were used for handling asynchronous requests. Unfortunately, using callbacks often led to hardly maintainable and readable code, especially for more complex asynchronous operations that involved making multiple requests and data transformations. You might have heard a specific term often associated with this situation—a callback hell.
In the example above, we have quite a bit of code just to fetch a list of people. Let’s add one more API call and handlers for it and see how readable it is.
import $ from "jquery";
function onFetchPlanetsSuccess(people) {
return function(data, textStatus, jqXHR) {
console.log("We got planets and people!", people, data);
};
}
function onFetchPlanetsError(jqXHR, textStatus) {
console.error("There was a problem while fetching planets");
}
function onSuccess(data, textStatus, jqXHR) {
console.log("People fetched successfully!", data);
get(
"https://swapi.co/api/planets",
onFetchPlanetsSuccess(data),
onFetchPlanetsError
);
}
function onError(jqXHR, textStatus, errorThrown) {
console.error("There was a problem while fetching people");
}
function onComplete(jqXHR, textStatus) {
console.log("Request completed");
}
function get(url, onSuccess, onError, onComplete) {
$.ajax(url, {
method: "GET",
success: onSuccess,
error: onError,
complete: onComplete
});
}
get("https://swapi.co/api/people", onSuccess, onError, onComplete);
The more calls we have to make, the uglier and more troublesome it becomes to maintain our code. It is also a bit harder to follow execution flow. Fortunately, those days are behind us, as now asynchronous actions can be handled with Promises and Async/Await.
First, let’s see what Promises are.
Promises were added to JavaScript in ES6, also known as ECMAScript 2015. The reason for it was to simplify handling of asynchronous requests. Promise
is a proxy for a value which is not yet known at the time of promise creation. A promise can be in three different states:
Let’s see how promises can be used:
function get(url) {
// Return a new promise object
return new Promise((resolve, reject) => {
// Initialise an api call
$.ajax(url, {
method: "GET",
success: function(data, textStatus, jqXHR) {
// API call was successful, so we resolve the promise
// it will change state to ‘fulfilled’
resolve(data);
},
error: function(jqXHR, textStatus, errorThrown) {
// API call failed, so we reject the promise
// it will change state to ‘rejected’
reject(errorThrown);
}
});
});
}
get("https://swapi.co/api/people")
.then(response => {
console.log("response", response);
})
.catch(error => {
console.log("There was a problem while fetching data.");
console.error(error);
})
.finally(() => {
console.log('request completed')
})```
The get
method now returns an instance of the Promise object. A promise expects to receive a function as a parameter, and it will pass resolve
and reject
functions to it as parameters. When a promise is initialized, it is in the pending state. The resolve
function is called if a request is completed successfully and would change promise’s state into fulfilled. If there is a problem during a request, then the reject
function is called and promise’s state will change to rejected.
To get a response from the API call when it is successful, we can chain the then
method; it will receive the response as the first parameter. If a request failed, then we chain the catch
method. Another method which can be chained is finally
.
Below you can find an example with the Fetch API. We do not have to use new Promise((resolve, reject) => {})
because the fetch
method by default returns a promise.
fetch("https://swapi.co/api/people")
.then(response => {
return response.json();
})
.then(people => {
return fetch(‘https://swapi.co/api/planets’)
.then(response => response.json())
.then(planets => {
return {
people,
planets
}
})
})
.then(({people, planets}) => {
console.log(‘result’, people, planets)
})
.catch(error => {
console.log("There was a problem while fetching data.");
console.error(error);
})
.finally(() => {
console.log('request completed')
})
Now we have less code, it is easier to follow and cleaner than the example with callbacks. However, be careful with promises, as they can also quickly become an unmaintainable mess, especially if there are a lot of nested promises. Therefore, try to keep them as shallow as possible and don’t nest them too deep.
We have covered basics of promises, so now let’s have a look at what Async/Await is about and how it can be used to improve our asynchronous code handling.
In ECMAScript 2017 a new feature to handle asynchronous requests was introduced—async functions and the await keyword. Async/Await works on top of promises and makes asynchronous code easier to read and write. The code looks more synchronous and, therefore, the flow and logic are more understandable. Especially when it gets more complex and involves more calls and transformations.
This is how we define an async function:
// Normal async function
async function fetchData() {
// perform action
}
// Async arrow function expression
const fetchData = async () => {
// perform action
}
The big difference is just an addition of the async keyword. However, thanks to it we can now await promises. Below you can find the example from before, but now rewritten with async/await.
async function fetchData() {
try {
const peopleResponse = await fetch("https://swapi.co/api/people");
const people = await peopleResponse.json();
const planetsResponse = await fetch("https://swapi.co/api/planets");
const planets = await planetsResponse.json();
console.log("data", people, planets);
} catch (error) {
console.log("There was a problem while fetching data.");
console.error(error);
} finally {
console.log("Request completed");
}
}
fetchData();
There is no need to chain any methods, as when the JavaScript engine gets to the await keyword, it will not proceed to the next line of code until the promise we are awaiting is resolved. We do not use then
and catch
chaining anymore, and, therefore, to handle any errors we must use try/catch.
We have successfully reduced the amount of code required to fetch data immensely. The code is much easier to maintain and looks more synchronous so it is easier to reason through.
The await keyword can only be used inside of an async function. Otherwise, an error will be thrown. However, at the time of writing this article, there is a top-level-await proposal which currently is in stage 3. It would allow using await outside of an async function. You can read more about it here: https://github.com/tc39/proposal-top-level-await.
Our previous example with async/await is much better than previous attempts with callbacks and promises, but there is one improvement we can make. We are making two API calls: one to fetch people and one to fetch planets. However, before the latter API call can be made, the former must finish first. This is due to how async/await works, and it is a waste of time if the second API call does not rely on the first one in any way.
Therefore, let’s have both calls execute in parallel. We can use Promise.all
for that.
async function fetchData() {
try {
const fetchPeoplePromise = fetch("https://swapi.co/api/people").then(response => response.json());
const fetchPlanetsPromise = fetch("https://swapi.co/api/planets").then(response => response.json());
const [people, planets] = await Promise.all([fetchPeoplePromise, fetchPlanetsPromise])
console.log("data", people, planets);
} catch (error) {
console.log("There was a problem while fetching data.");
console.error(error);
} finally {
console.log("Request completed");
}
}
Both requests are initialized as soon as possible. As we did not use the await keyword on any of the fetch requests, JavaScript engine will keep executing code until it gets to the await Promise.all line. Promise.all will wait for all promises that were passed in an array to fulfill. If any of the promises are rejected, then an error will be thrown, and it will be handled in the catch block.
Personally, I use async/await over pure promises whenever I can. However, writing try/catch all the time can be quite tedious. So, here is a little snippet that can be used to help with that:
const withAsync = async fn => {
try {
const response = await fn()
return [response, null]
} catch (error) {
return [null, error]
}
}
const [people, error] = await withAsync(() => fetch("https://swapi.co/api/people").then(response => response.json())
if (error) {
console.error(error)
return
}
console.log('we have people!', people)
There is no need to write try/catch all the time. Instead, it is encapsulated in the withAsync function. If there is an error, we can handle it and bail out, and if everything is alright, we can handle the response.
We have covered how asynchronous actions in JavaScript can be handled with callbacks, promises and async/await. These are key features for JavaScript and Angular. The code examples clearly show how cumbersome it used to be in the past to handle API calls. At least now if you have to work with a legacy project, you might know where to start and how to convert older code to use a more modern approach.
Thomas Findlay is a 5-star rated mentor, full-stack developer, consultant, technical writer and the author of “React - The Road To Enterprise” and “Vue - The Road To Enterprise.” He works with many different technologies such as JavaScript, Vue, React, React Native, Node.js, Python, PHP and more. Thomas has worked with developers and teams from beginner to advanced and helped them build and scale their applications and products. Check out his Codementor page, and you can also find him on Twitter.