As JS is single-threaded, only one operation can occure at the time. To be able to perform multiple operations at the same time but to also keep a track of their order of execution, we have to use different techniques to make sure these operations don't block the main thread and program execution. These are callbacks, event, promises and async/await. They allow us to run code asynchronously without blocking the program execution.
Let's write a function whose execution time will depend on some external influence - in this case the setTimeout()
method:
function printString(str) {
setTimeout(() => {
console.log(str);
// console.log(`It took ${Math.floor(Math.random() * 100)}ms for this function to execute`)
}, Math.floor(Math.random() * 100));
}
If we would call this function multiple times, each time we would get different results, no matter that we are calling them in the same order each time. This replicates asynchronous nature of any HTTP call or any other situation, when we really don't know hos much time it will take for each piece of code to execute:
function printAll() {
printString('A');
printString('B');
printString('C');
}
printAll();
If you execute the above code, you will notice that A, B, and C print in a different and random order each time you call printAll()!
This is because these functions are asynchronous. Each function gets executed in order, but each one is independent with its own setTimeout(). They won’t wait for the last function to finish before they start.
A callback is a function that is passed to another function. When the first function is done, it will run the second function.
function printStringWithCallbacks(str, callback) {
setTimeout(() => {
console.log(str);
callback();
}, Math.floor(Math.random() * 100));
}
function printAllWithCallbacks() {
printStringWithCallbacks('A', () => {
printStringWithCallbacks('B', () => {
printStringWithCallbacks('C', () => {
console.log('hello');
});
});
});
}
printAllWithCallbacks();
Problem with callbacks: it creates something called “Callback Hell.” Solution: Promises - here to fix this nesting problem.
Firstly, promises have cleaner (nicer) syntax, but also they make it much easier to understand what’s going. Each operation is guaranteed to await for a prior operation to finish before proceeding.
Promise allows us to handle asynchronous operations in a more synchronous fashion.
To create promise, we use new Promise
constructor that accepts 2 callbacks - one when the operation is successfully finished (resolve
), and the other one when there is some kind of error (reject
).
function printStringWithPromises(str) {
return new Promise((resolve, reject) => {
if (str) {
setTimeout(() => {
console.log(str);
resolve(str);
}, Math.floor(Math.random() * 100));
} else {
const err = new Error('String is never passed in!');
console.log(err);
reject(err);
}
});
}
A Promise can be in one of the following three states:
- Pending. When a Promise gets created, it is in a pending state, until it gets fulfilled or rejected.
- Fulfilled. When the
resolve()
is called, the Promise goes to a fulfilled state. - Rejected. When the
reject()
is called, or if an Error is thrown, the Promise becomes rejected.
How do we use promises?
To consume a Promise, we attach a .then()
method to the Promise. The .then()
method recieves a another function as an argument, and that is the resolve
portion of the promise - when promise is fullfilled.
On the other hand, to handle a rejected Promise - we attach the .catch()
method to the Promise. Also, the errors will be handled in the catch()
part of the promise.
// schooly way
printStringWithPromises("A")
.then(val => {
console.log(`Passed: ${val}`);
return printStringWithPromises("B")
})
.then(val => {
console.log(`Passed: ${val}`);
return printStringWithPromises("C")
})
.then(val => {
console.log(`Passed: ${val}`);
})
.catch(err => console.log(`Error ocurred: ${err}`))
// Passed: A
// Passed: B
// Passed: C
// all in one approach
function printAllWithPromises() {
printStringWithPromises('A')
.then(() => printStringWithPromises('B'))
.then(() => printStringWithPromises('C'))
.catch(err => console.log(`Error happened: ${err.message}`));
}
printAllWithPromises();
To summarize: Promises manage asynchronous code, and using then()
and catch()
we can extract values from a Promise.
Await is basically syntactic sugar for Promises. It makes your asynchronous code look more like synchronous/procedural code, which is easier for humans to understand.
The printStringWithPromises()
function doesn’t change at all from the promise version.
async function printAllWithAsync() {
await printStringWithPromises('A');
await printStringWithPromises('B');
await printStringWithPromises('C');
}
printAllWithAsync();
So what does async
mean in the function definition? On its own, the async
keyword transforms a function so that when the function is invoked, the return value will be wrapped in a Promise, like:
async function getName(name){
return name
}
getName("sandra"); // => nothing in the console
getName("sandra")
.then(passedVal => console.log(passedVal))
// prints "sandra" in the console
What does await
do? Using the await
keyword before a Promise pauses the async function until that asynchronous operation is finished.
Error handling in async/await manner - try
/ catch
- The try block is where we execute the code.
- The catch block is used for handling Errors and rejections.
const printAllWithAsync = async () => {
try {
await printStringWithPromises('A');
await printStringWithPromises('B);
await printStringWithPromises('C');
} catch(err) {
console.log(err)
}
}
printAllWithAsync()