Callbacks/Promises/Observables/Async-Await
“That man who is regular and punctual will get sure success in all walks of life.”
― Sivananda Saraswati, Sure Ways For Success In Life And God Realisation
In my last article, I talked about how the Node Event Loop works in Javascript and how it handles asynchronisity. In Javascript, we have a multitude of options on how we handle asynchronous events.
Table of Contents
What is Asynchronous Code?
First things first, what is an asynchronous event?
const getData = () => {
setTimeout( () => {
return { data: ['there', 'is', 'stuff', 'here'] }
}, 100)
}
const data = getData();
console.log(data);
The console.log here logs undefined
. The reason is because the getData
function is an asynchronous function. Because Javascript's single threaded nature, it doesn't wait for the asynchronous event to finish before continuing. (To learn how this works, click here.)
So how do we fix this? The first solution is...
Callbacks
Callbacks are the oldest way of working with asynchronous events. Basically, the way it works is a callback gets passed in as a parameter into a function. When the asynchronous event completes, the callback function is executed (with access to the data from the asynchronous event).
const getData = (cb) => {
setTimeout( () => {
cb({ data: ['there', 'is', 'stuff', 'here'] })
}, 100)
}
getData( data => {
console.log(data);
});
This lets us manipulate the data however we want inside the callback function. We know that whenever the event completes, the data will run. And of course, just like the very first example, the event is non-blocking so our code will continue to run while waiting for the event to complete.
Callback Hell
One of the problems we have with callbacks is something we call callback hell. When we have multiple asynchronous operations to do, we can't chain them. So our only option is nesting them.
Consider the following:
// Pretend this is an api that only allows for getting one user data at a time.
let getUserData = (user, cb) => {
userData = {
john: {data: 'stuff about john'},
bob: {data: 'stuff about bob'},
george: {data: 'stuff about george'}
}
setTimeout( user => {
cb(userData[user]);
} , 200)
};
The code above is a fairly simple. It mimics an API call that fetches a specific user from userData
. It retrieves it and lets us apply the callback to whatever data we're getting back.
Using it once:
getUserData('john', johnData => {
console.log(johnData);
});
What if we need to use it multiple times?
getUserData('john', johnData => {
getUserData('bob', bobData => {
getUserData('george', georgeData => {
// here, i have access to john, bob, and georgedata.
console.log(johnData, bobData, georgeData);
}
};
});
This is a pretty simple callback and it already looks like a mess. Imagine if things got a little more complicated.
So callbacks aren't perfect. If handling multiple asynchronous functions, it could end up turning our code into a mess. An alternative is...
Promises
A promise represents the eventual result of an asynchronous operation. It is a placeholder into which the successful result value or reason for failure will materialize.
With ES6 (or ES2015), we get promises, natively. (There are polyfills for ES5).
Mocking out that API again, here is how an API using promises might look.
let getUserData = (user) => {
userData = {
john: {data: 'stuff about john'},
bob: {data: 'stuff about bob'},
george: {data: 'stuff about george'}
}
return new Promise( resolve => {
setTimeout( () => {
resolve(userData[user])
}, 200)
});
};
The getUserData
code might look a little complicated, but lets break it down really quick. Our function returns a new promise. The promise takes in two different functions: resolve and reject. Resolve means to complete the promise and send it back. Reject means we can throw an error if we want to.
But despite the initial complexity of promises, using them is much easier and cleaner.
getUserData('john')
.then( johnData => {
console.log(johnData);
})
.catch( err => {
console.error(err);
});
As you can see, we can just call getUserData()
with whatever parameter we need. Then we can use .then()
to make it do whatever we want it to do. We can even handle errors better to catch any rejects using .catch()
. And because these are all properties on the Promise object, it allows us to do dot-chaining. As long as whatever we're returning from inside the then statement is another promises we can just chain all of our promises together.
Here's how promises look when chaining multiple asynchronous operations that are dependent on each other:
validateLogin(creds)
.then( status => {
return getUserData(status);
})
.then( userData => {
console.log(userData);
});
Boom. Clean, simple, and easy to comprehend.
Streams
As promises solved callback hell, it doesn't solve another problem: streams. Promises are great for handling HTTP requests or one time resolutions. But anything that we need to wait for multiple things, promises are not the way to go. Imagine a DOM selector for a click event.
const button = document.querySelector('button');
const handleClick = () => {
return new Promise(resolve => {
button.addEventListener('click', () => {
resolve(event);
});
});
};
handleClick().then(event => {
console.log(event.target);
});
The promise is supposed to fire off every time we click the button. But a promise can only resolve once. After the first click, the promise resolves and all further clicks do nothing. Whats our next alternative?
Observables
Observables are introduced by a library called ReactiveX (or RxJS for the Javascript portion).
To stay consistent with this first, let's mock out what an API call would look like first.
let getUserData = (user) => {
userData = {
john: {data: 'stuff about john'},
bob: {data: 'stuff about bob'},
george: {data: 'stuff about george'}
};
return Observable.create( observer => {
observer.next(userData[user]);
});
};
let subscription = getUserData('john')
.subscribe( johnData => {
console.log(johnData);
});
Similarly to Promises, we get dot-chaining and powerful methods on the observable that we can use to change our data stream (like map
, filter
, reduce
). Something to remember while using Observables is that the observable stream is there when you call the function, but no data will be passed through until .subscribe()
is called. Think about it like a garden hose, there's water inside the hose the entire time, but no water actually comes out until the subscription happens.
Back to the problem of solving data streams (like multiple click events).
const button = document.querySelector('button');
const observable = Rx.Observable.fromEvent(button, 'click');
let subscription = observable.subscribe(
(event) => { console.log(event.target) },
(error) => { console.log(error) },
(completion) => { console.log('completion') }
);
Now we have a subscription to the stream. Whenever a click happens, it will fire off a new event into the stream and the console.log
will fire.
In regards to error handling and completion, the subscribe function accepts two additional, optional arguments. The first is the error handling (calls when error occurs). And the second is the on completion function (calls after success or error).
Unsubscribing
You might've noticed that in all the subscriptions I have made a variable subscription
and assigned it to the subscription of our variable; one of the biggest gotchas that can happen with observables is forgetting to unsubscribe to your stream. If you have a single-page application, that subscribes to a stream when you go to a specific component and you never unsubscribe, if the user navigates away, then comes back to that component, the stream will just open more and more streams to the same source causing memory leaks.
This is how we unsubscribe from an event:
let subscription = observable.subscribe( event => { console.log(event.target) } );
subscription.unsubscribe();
Fortunately, unsubscribing is very easy; it's just remembering to do it. Other ways to unsubscribe is using the takeUntil
or take
methods (which closes the stream until an event or until a specific amount of data goes through, respectively).
Of all the methods, we've gone through so far, the most powerful of which is Observables. It lets us handle asynchronous calls, subscribe to streams, and even provides a very robust set of tools to modify our data. But because of how powerful it is, RxJS does need a bit of learning to make sure you know what you're doing. Little things like forgetting to unsubscribe can cause you some headaches if you don't know what you're looking for.
Now, if you're looking a little simpler, we have one last alternative.
Async/Await
It's also being implemented in ES8 (aka ES2017).
The whole idea behind Async/Await is to be able to write asynchronous code, synchronously. While callbacks, promises, and observables all give us different ways to handle this, we're still writing code inside a block. Whether it's inside a callback function, inside a then block, or down the subscription chain (or in a subscribe call), it's still inside the code block. Wouldn't it be great if we could write our asynchronous code... synchronously? With Async/Await, we can! Let's take a look.
async function fetchUser(creds) {
const auth = await validateLogin(creds); // <- async operation
const user = await getUserData(auth); // <- async operation
return user;
}
fetchUser().then( userData => console.log(userData));
Sure, we still need to use .then
to access our user data, but if we take a look inside fetchUser
, the asynchronous events are written as synchronous code. This makes the code extremely easy to understand and readable.
We have two new keywords here: async
and await
. The async
keyword lets Javascript know that it's going to be an asynchronous function. await
tells the code to wait for the async function to finish before continuing.
Async/await is essentially an extension on Promises. We could rewrite the code like this:
function fetchUser(creds) {
return validateLogin(creds)
.then( status => getUserData(status) )
.then( userData => userData );
}
fetchUser().then( user => console.log(user) );
The await
simply waits for the promise to resolve. Then it runs the "awaited" code as if it was inside the .then()
block. So depending on how you look at it, the code is much more straightforward and easier to understand.
Error handling using Async/Await is simple too! Because we're writing (fake) "synchronous" code again. We can go back to our trusty friend try-catch
.
async function fetchUser(creds) {
try {
const auth = await validateLogin(creds); // <- async operation
const userData = await getUserData(auth); // <- async operation
return userData;
} catch (err) {
console.error(err);
}
}
fetchUser()
.then((user) => console.log(user.name));
So which one should I use?
I think whenever you're deciding on whatever tool you're trying to use it comes down to two main factors: Amount of Uses vs. Power
The first factor ("Amount of Uses") is thinking about how much overhead there is to using whatever technology you're trying to use. If you're making a quick easy application that only needs one API call, then do you even need to use anything other than a simple callback?
Callbacks aren't bad at all. If you're only doing one asynchronous event, then the code will look just as clean and readable as everything else here. (Callbacks only kinda suck after you have to start nesting them.)
On the other hand, if you know you'll be doing multiple async functions, along with data streams then it's probably worth it to install RxJS. Drag and drop events on the DOM are alot of work when using pure Javascript. Whereas using Observables it's basically a oneliner. Another great use for Observables are holding an App State for your entire application. You can subscribe to the state and whenever anything gets updated, it will push a new state to your whole app.
If you don't need the entire arsenal that Observables provide, but still want to do chained async events, then maybe Promises are for you. Easy to use and simple to comprehend, Promises bring forward a balance of power and usability.
If you prefer to write synchronous asynchronous code, instead of writing in blocks then perhaps try out async/await. But don't forget, async/await is really just using Promises under the hood.
Another thing to consider is that where Promises(ES6) and Async/Await (ES8) are native now, Observables are not. Which means if you do chose to use it, you'll have to install RxJS into your code base which could add additional overhead to your bundle (or whatever it is you're using). But at the end of the day, it's all about using the right tool for the job and what you value in your code.
I hope this article gave you some insight in what tools we have available for handling asynchronous code, as well as help you pick what tool you want to use for your next project!