Breakdown of Async/Await in Javascript
Callbacks, Promises, Iterators & Generators
Nowadays, when we are making an API request, we are writing something like
const res = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=${location}&appid=${key}`);
console.log(res);
But how does it work under the hood, and how did it come here?
Callbacks
Before await/async
, there were callbacks. Pretty basic idea we are passing a function that we want to be executed when response arrives
function handleResponse(err, res, body) {
// Do stuff
}
request('https://www.somepage.com', handleResponse);
However, this quickly becomes hard to handle because, in the real world, you have multiple asynchronous tasks that depend on each other, so that you will end up with a lot of nested callbacks known as callback hell.
Promise
To make asynchronous work easier Promise were introduced, promises are objects that holds three properties: an "array" of methods that need to be executed when response arrives, to add functions to this "array" we can use then
, catch
, and/or finally
, state of promise fulfilled, rejected or pending, and result/response.
We can manually create a promise object like so
const examplePromise = new Promise((resolve, reject) => {
// do stuff that takes time
// then resolve(/*response*/); // triggers functions added passed with then
// or reject(/*error response*/); // triggers functions added passed with catch
});
or we can call method that returns promise
const weatherPromise = fetch(`https://api.openweathermap.org/data/2.5/weather?q=${location}&appid=${key}`)
.then(res => console.log(res))
.catch(err => console.error(err))
.finally(() => console.log('Done!'));
Immediately when this line gets executed, weathePromise gets a promise object with an "array" of methods to execute after resolving the promise, with PromiseState
pending and with PromiseResult
undefined. And After Xms response comes, PromiseResult gets assigned value of the response, PromiseState becomes fulfilled, and necessary methods get executed. This pattern is better than callbacks and is easy to understand; however, it can also becomes ugly and hard to read.
Iterators
Iterators are functions that return new values every time you invoke them. To create an Iterator, you need to implement an iterator protocol, a protocol is basically the same as an interface in other languages. To implement it, you need an object with a method next
that returns an object with two properties, value
and done
.
You can implement it using a function that creates an iterator object, or directly adding method to an object.
function makeIterator(array) {
let index = 0;
return {
next: function() {
const result ={
value: array[index],
done: index >= array.length
};
index++;
return result;
}
};
}
const iterator = makeIterator([1, 2, 3]);
console.log(iterator.next());
// { value: 1, done: false }
console.log(iterator.next());
// { value: 2, done: false }
console.log(iterator.next());
// { value: 3, done: false }
console.log(iterator.next());
// { value: undefined, done: true }
Also by adding [Symbol.iterator]
property to your object now you can use your object inside of for ... of
loops. (spread operator also uses that method)
const iterator = {
index: 0,
numbers: [1, 2, 3],
next: function() {
const result ={
value: this.numbers[this.index],
done: this.index >= this.numbers.length
};
this.index++;
return result;
},
[Symbol.iterator]: function() { return this; }
};
for (let number of iterator) {
console.log(number);
}
// 1
// 2
// 3
console.log([...iterator]);
// [ 1, 2, 3 ]
Instead of return this;
from [Symbol.iterator]
you can return and object with next()
property on it, and remove next()
from you object. (see example below)
Moreover, there also exists [Symbol.asyncIterator]
property, defining which give your object the ability to be used in for await ... of
loop ๐คฏ
For example asyncIterator
can be used in case if you have a list of APIs that you need to call.
const apiList = {
numbers: [1, 2, 3],
[Symbol.asyncIterator]: function() {
let index = 0;
return {
next: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
let currentIndex = index;
index++;
resolve(
{
value: this.numbers[currentIndex],
done: currentIndex >= this.numbers.length
}
);
}, 300);
});
}
};
}
};
async function callApis() {
for await (let number of apiList) {
console.log(number);
}
}
callApis();
// 1 (with 300ms delay)
// 2 (with 300ms delay)
// 3 (with 300ms delay)
Generators
Generators are kind of evolved iterators or a more convenient way of writing iterator.
function* makeIterator() {
yield 1;
yield 2;
yield 3;
}
const iterator = makeIterator();
console.log(iterator.next());
// { value: 1, done: false }
console.log(iterator.next());
// { value: 2, done: false }
console.log(iterator.next());
// { value: 3, done: false }
console.log(iterator.next());
// { value: undefined, done: true }
All example from Iterator section can be rewritten using Generators
const iterable = {
*[Symbol.iterator]() {
yield 1;
yield 2;
yield 3;
}
}
for (let value of iterable) {
console.log(value);
}
// 1
// 2
// 3
console.log([...iterable]);
// [ 1, 2, 3 ]
The main element of Generator is the yield
keyword; it returns a value and immediately freezes the execution of the function. What execution of this snipped will result in?
function* makeIterator() {
let num = yield 1;
yield num;
}
const iterable = makeIterator();
for (let value of iterable) {
console.log(value);
}
Correct answer
1
undefined
We are getting yielded out before num gets assigned any value, so it becomes undefined
. You can pass a value back inside of Generator by providing it as an argument to next
method, and this value will be placed in a place where the last yield was executed.
function* makeIterator() {
let num = yield 1;
yield num;
}
const iterable = makeIterator();
console.log(iterable.next());
console.log(iterable.next(42));
// { value: 1, done: false }
// { value: 42, done: false }
Async and Await
Now, why did we go on this Iterator and Generator tangent?
Because under the hood, async functions are asynchronous generators.
function continueExecution(value) {
caller.next(value);
}
function* createApiCaller() {
const response = yield fetch('API_URL');
console.log(response);
}
const caller = createApiCaller();
const call = caller.next();
call.value.then(continueExecution);
// response gets console.loged aftex Xms
Using Generator and promise, we made function createApiCaller
behave the same way as if it was asynchronous async createApiCaller()
๐ง๐ปโโ๏ธ. But thanks to async/await, we can skip all this and focus on the important parts and make code way more readable.
async function callApi() {
const response = await fetch('https://reqres.in/api/users/2');
console.log(response);
}
callApi();