Async JavaScript
sourceAsync performs tasks that may take some time to complete, without blocking the system; Code that can be started now but finished later.
If during the running of a synchronous or procedural program, the code needs to make a request to an external database, one that may take some time to complete, the program will essentially stall; This is known as blocking code.
To get resolve this issues in javascript, what we tend to do is to use an asynchronous function into which we pass a callback, which is to say, another function, one that will be called by the blocking function once the request has been responded too, to complete the task that was awaiting whatever was blocking.
asynchronous code example
Here we will set a setTimout() to emulate an asynchronous function getting date from the network; When the code runs, the timeout code that has a delay set to two seconds before running the callback function does not block the rest of the code from running.
console.log(1);
console.log(2);
setTimeout(() => {
console.log("callback function fired");
}, 2000);
console.log(3);
console.log(4);
// 1
// 2
// 3
// 4
... A two second pause ...
// callback function fired
HTTP requests
- Make HTTP requests to get data from another server
- We make these requests to API endpoints
- - Example API endpoint
- http://www.musicapi.com/artist/moby
XMLHttpRequest
We will be using the https://jsonplaceholder.typicode.com web site which emulates API returning mock data. To make these requests we first need to make an XMLHttpRequest object. We then open and define the request, then we finally make the request.
const request = new XMLHttpRequest();
request.open('GET', 'https://jsonplaceholder.typicode.com/todos/');
request.send();
Running this code we can see in the console that the request is being fulfilled, but we need some way to know within our code when this is complete and how to access that data.
From within our code we can track the progress of the request using an event listener, and a specific event called ready state changed.
request.addEventListener
We pass the callback function into the listener and for our example we will have it log to the console both the request and the readyState, all this of course before making the request.
request.addEventListener('readystatechange', () => {
console.log(request, request.readyState);
});
readyState
In our console we see that there are four lines output by our function, each has an XMLHttpRequest object and an integer, the readyState, 1, 2, 3 and 4. These states are described as follows...
The 0 ready state is not returned as this is prior to the request having been sent and as such no event has been triggered.
Value | State | Description |
---|---|---|
0 | UNSENT | Client has been created. open() not called yet. |
1 | OPENED | open() has been called. |
2 | HEADERS_RECEIVED | send() has been called, and headers and status are available. |
3 | LOADING | Downloading; responseText holds partial data. |
4 | DONE | The operation is complete. |
Status Codes
In our request code above, we are simple checking that the readyState is 4 but this is not really enough, if there was some sort of error in the the request then this will not be OK.
For an example if we create an endpoint that is not valid
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4) {
console.log(res);
}
});
request.open('GET', 'https://jsonplaceholder.typicode.com/todosos/');
request.send();
To overcome this we need to check the status as well as the ready state, when the response comes back. The status of a request that has worked is 200.
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 ∧∧ request.status === 200) {
console.log(request.responseText);
} else if (request.readyState === 4) {
console.log('Could not retrieve the data.');
}
});
request.open('GET', 'https://jsonplaceholder.typicode.com/todoss/');
request.send();
Request Wrapper Function
It is going to be far more practical for us to put this request code into a function.
const getTodos = () => {
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 ∧∧ request.status === 200) {
console.log(request.responseText);
} else if (request.readyState === 4) {
console.log(`Could not retrieve the data.`);
}
});
request.open('GET', 'https://jsonplaceholder.typicode.com/todoss/');
request.send();
}
We can now retrieve the todo data by calling the getTodo() function!
Adding a callback
If we add a callback function parameter to our getTodos function we can specify the behaviour of the function when it is called, making the function far more useful.
const getTodos = callback => {
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 ∧∧ request.status === 200) {
callback();
} else if (request.readyState === 4) {
callback();
}
});
request.open('GET', 'https://jsonplaceholder.typicode.com/todos/');
request.send();
}
getTodos(() => {
console.log('callback fired')
});
Callback with data and status
We now need to add parameters to our callback function so as to really get the information from the call that we need, the convention in javascript is to place the error before the data in the parameter list.
getTodos((err, data) => {
if (err) {
console.log(err);
} else {
console.log(err, data);
}
});
Returning to the notion of async code
We can demonstrate now that our getTodos() function is asynchronous by calling it before printing out some other data, so see whether it blocks or not.
getTodos((err, data) => {
if (err) {
console.log(err);
} else {
console.log(err, data);
}
});
console.log(1);
console.log(2);
console.log(3);
console.log(4);
We can clearly see that the count to four has completed before the response has returned.
Using JSON Data
The JSON data that we often receive from API's looks like a javascript object but it is just a string. The JSON format is that of a string as text strings are the standard data format used in the HTTP protocol which is the foundation of the internet. We need a way to take this JSON format and make it into a real javascript object.
There is an object that is built into the javascript language that was designed expressly with this task in mind, JSON.
JSON.parse()
const data = JSON.parse(request.responseText);
In practice this gives us:
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 ∧∧ request.status === 200) {
const data = JSON.parse(request.responseText);
callback(undefined, data);
} else if (request.readyState === 4) {
callback('could not retrieve data', undefined);
}
});
request.open('GET', 'https://jsonplaceholder.typicode.com/todos/');
request.send();
Hand written JSON
We can also write JSON by hand, it is often stored in files with the postfix .json as a way of storing JSON data persistently. When writing JSON it is very similar to describing an object in javascript though there are some differences. Every string, including the member names should be quoted with double quotes, where as other types such as booleans and numbers should be written directly.
[
{ "person": "Fred", "todo": "wash car": "priority": 2, "complete": true },
{ "person": "Jane", "todo": "repair bike": "priority": 4, "complete": true },
{ "person": "Jean", "todo": "cook dinner": "priority": 7, "complete": false}
]
We could just as well use the url of a JSON file as calling to an API to load data.
request.open('GET', 'assets/json/todo.json');
Callback Hell
Supposing that we would like to get the data from multiple files, and for display purposes we need to wait for the results of one request before making the next, and so on. This is often the case when working with data from different API's. We may actually require the data from one API in order to formulate the request from another, as such we have no choice but to make the requests in turn. We may actually require the data from one API in order to formulate the request from another, as such we have no choice but to make the requests in turn.
Before doing this we need to make a change to our request function, up until now we have been hard coding the address into the function, this will have to be made more generic for making several request, so that we can pass the address into the function.
const getTodos = (resource, callback) => {
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 ∧∧ request.status === 200) {
callback(undefined, request.responseText);
} else if (request.readyState === 4) {
callback('could not retrieve data', undefined);
}
});
request.open('GET', resource);
request.send();
}
We can get the requests to happen one after the other by calling the from inside each other, recursively.
getTodos('assets/json/fred.json', (err, data) => {
if (err) {
console.log(err);
} else {
console.log(data);
getTodos('assets/json/jane.json', (err, data) => {
if (err) {
console.log(err);
} else {
console.log(data);
getTodos('assets/json/jean.json', (err, data) => {
if (err) {
console.log(err);
} else {
console.log(data);
}
});
}
});
}
});
This code is already starting to look pretty messy with one call nested inside of another, nested inside another; This is what is often referred to as callback hell. This kind of nested code is very hard to read and difficult to maintain. To resolve this issues we can use something called 'promises'.
Promises
Demonstrating first how with a simple example we will then go on to solve the previous codes conundrum using promises.
Promises always return either a result or an err, conventionally we pass into their callback functions two parameters, resolve and reject, these two parameters are functions that are built into the Promise API. The resolve function takes as its parameter the data that we are hoping to obtain, else upon failure to obtain the data reject takes the returned error; Putting aside the reject response for now we get the following example.
resolving a promise
const getSomething = () => {
return new Promise((resolve, reject) => {
// Fetch something.
resolve('some data');
});
};
When we call getSomething it returns to us a Promise, we can call a method on this promise .then() then is a callback function which expects a function as its argument, the promise acts upon this function upon completion .
getSomething().then(data => {
console.log(data);
});
rejecting a promise
The Promise callback .then method actually expects two callback functions, the second of these functions is called upon reject, it is fired only if the operation returns and error.
const getSomething = () => {
return new Promise((resolve, reject) => {
// Fetch something.
reject('some error');
});
};
getSomething().then(data => {
console.log(data);
}, err => {
console.log(err);
});
This way of writing the call to getSomething can be a bit messy, there is a second way that we could also have written it which makes use of a second method upon the Promise called .catch, catch will now treat the case that there is a error returned.
getSomething().then(data => {
console.log(data);
}).catch(err => {
console.log(err);
});
returning to our getTodos
Applying all of this to our getTodos function, first we need to create and return the Promise from our original getTodos function call. Then, within that function, we put our request code. Within our call to getTodos, we no longer need to pass a callback function, as these are implicit to the Promise itself, our getTodos function can now use the resolve and reject functions in response to the request.
const getTodos = (resource) => {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.addEventListener('readystatechange', () => {
if (request.readyState === 4 && request.status === 200) {
const data = JSON.parse(request.responseText);
resolve(data);
} else if (request.readyState === 4) {
reject('error getting resource');
}
});
request.open('GET', resource);
request.send();
});
};
getTodos('assets/json/fred.json').then(data => {
console.log('promise resolved:', data);
}).catch(err => {
console.log('promise rejected');
});
Chaining Promises
One of the best things about promises is that we can chain the together, performing one asynchronous task after another in order, should we need to.
Returning to our getTodos we need to get three files on after the other, buy returning a Promise from our getTodos function we are then able to also include it in our promise chain, because it returns a promise we can use the .then method again after the getTodos function call.
getTodos('assets/json/fred.json').then(data => {
console.log('promise 1 resolved:', data);
return getTodos('assets/json/jane.json');
}).then(data => {
console.log('promise 2 resolved', data);
return getTodos('assets/json/jean.json');
}).then(data => {
console.log('promise 3 resolved', data);
}).catch(err => {
console.log('promise rejected');
});
The good thing about the catch at the end of the chain is that it catches any errors, so we do not need to write it again for each promise returned. If any one of the three getTodos returns an error, it will be caught by the single catch method call at the end of the chain, making the code considerably tidier.
Making one of the file names erroneous, demonstrates that this is the case.
getTodos('assets/json/fred.json').then(data => {
console.log('promise 1 resolved:', data);
return getTodos('assets/json/notthere.json');
}).then(data => {
console.log('promise 2 resolved', data);
return getTodos('assets/json/jean.json');
}).then(data => {
console.log('promise 3 resolved', data);
}).catch(err => {
console.log('promise rejected');
});
The Fetch API
The fetch API is built into the javascript language, it simplifies the acquisition of data considerably by performing much of the above in one singe call. However, it will serve us well to have understood the older methods in our approach to using these calls.
fetch('assets/json/fred.json');
The fetch function returns a promise, which is to say that it will either resolve if we have a success or reject if we get and error. As such we can add on a .then method to provide a function to run when there is success, and a .catch method for the case that fetch rejects.
fetch('assets/json/fred.json').then(response => {
console.log('resolved:', response);
}).catch(err => {
console.log('rejected:', err);
});
We see here that fetch returns an object, we see a little later on, how to extract the data from this object.
Errors from fetch
For us to receive and error from the fetch call, there has to have been a network error, or system error, if the resource is not found the response should still return correctly, however it will contain a 404 status code indicating that it has not found the resource. If we want to deal with this specifically, then we must check the status code of the response and not the error returned from fetch.
fetch('assets/json/notthere.json').then(response => {
if (response.status == 404) {
console.log('status:', 404);
} else {
console.log('resolved:', response);
}
}).catch(err => {
console.log('rejected:', err);
});
Getting the Data
response.json()
The JSON object that is returned by fetch contains our data, however it is still in the form of a Response. If we want to access the JSON inside the Response object, we need to call the .json method of the Response. This method returns a promise, we can add this to our chain by using a return statement and as such the code will also await the response of the promise, be that whether it is resolved or rejected. To access this returned value we add on another .then method after the fetch.
fetch('assets/json/fred.json').then(response => {
console.log('resolved:', response);
return response.json();
}).then(data => {
console.log('data:', data);
}).catch(err => {
console.log('rejected:', err);
});
Async & Await
Async and await are in 2023 quite modern features in the javascript language, what they allow us to do is to chain promises together in a much cleaner and more readable way.
Our fetch code is already far cleaner than our callback code, but when we start to chain a lot of different promises together, it can still become unwieldy. What async and await allow us to do is to section off all of our asynchronous code into a single function, and then use the await keyword to chain promises together in a far more readable way.
async
All that we need to do to make a function asynchronous is to add the async keyword before the brackets in a function expression. The use of the async keyword makes the function return a promise.
const getTodos = async () => {};
await
When calling fetch inside of our async function, we no longer need to use .then to insure that the code runs procedurally, we can use the await key word to pause the running of further code until the asynchronous code has returned. The call to fetch with await prepended retrieves the result from the promise that is returned by fetch.
const getTodos = async () => {
const response = await fetch('assets/json/fred.json');
};
We might thing that we are blocking code with our await function, but we need to remember that this is all happening within a wrapping function that is itself asynchronous, and non-blocking, as such the program outside of the function is still running.
As we have received a Response object, we need to call the .json() method upon it to retrieve its content, remembering that this also returns a promise, we can again use the await keyword to insure that the promise has resolved before we attempt to access it.
const getTodos = async () => {
const response = await fetch('assets/json/fred.json');
const data = await response.json()
return data
};
When coming out of an async function, to perform some task when it is done we use the .then method again.
const getTodos = async () => {
const response = await fetch('assets/json/fred.json');
const data = await response.json()
return data
};
getTodos()
.then(data => console.log('resolved:', data));
We are not yet treating the error case but will be addressing this a little later. For now we will show that this is non blocking.
getTodos()
.then(data => console.log('resolved:', data));
console.log(1);
console.log(2);
console.log(3);
console.log(4);
Throwing Errors
The code that we have just written is not yet treating the error, if our response.json() fails because the response does not contain valid JSON then the promise will be rejected, we can catch this with the .then method upon the async function call.
const getTodos = async () => {
const response = await fetch('assets/json/fred.json');
const data = await response.json()
return data
};
getTodos()
.then(data => console.log('resolved:', data));
.catch(err => console.log('rejected:', err.message));
throw
As the code is currently, if there is an error within the formatting of the JSON, we get the correct error message in the console. However if there is an error returned from fetch, as we have previously seen, the promise is not rejected but a resource not found status code is set inside the response. As such the error that is returned is from the attempt at calling the json method upon the response that contains no JSON. To resolve this issue we need to test the status and generate an error if the status is not what we expect it to be.
When we throw an error inside of an asynchronous function the promise that it returns is rejected. If we are calling the .catch method in the function chain then the thrown error is caught by that catch call.
const getTodos = async () => {
const response = await fetch('assets/json/notthere.json');
if (response.status !== 200) {
throw new Error('failed to return data');
}
const data = await response.json();
return data
};
getTodos()
.then(data => console.log('resolved:', data))
.catch(err => console.log('rejected:', err.message));