What are Observables? A 3-step guide to understanding them
Mathuti Sivamanogaran6 min read
Observables are one of the things I struggled most with when I first started coding in Angular. With the library RxJS, they are used for managing asynchronous operations, handling events, managing states, and creating dynamic user interfaces. This article will go through some of the many thoughts I had when starting with Angular, to give a new perspective and a better understanding of what Observables are and how to best think when using them. And for that, what’s better than trying to build an app to navigate space and its many stars? (Nothing)
📺 Level 0: Don’t forget to subscribe! (to the observable)
One example of how an observable is used is to handle HTTP requests.
My alien best friend and I want to be able to travel in space, more specifically to go see all the many constellations that cover the Earth’s sky. To do that, we would like to make a very simple app. The thing is, we only know the most famous stars, like Sirius, and not the constellations they belong to.
Thus, we need to make an HTTP call to an API to retrieve the constellation: ConstellationHttpCall
, that takes a star name as parameter.
let constellation: string;
constellation = ConstellationHttpCall('Sirius')
console.log(constellation)
//*Observable {source: Observable, operator: ƒ}*
This doesn’t seem to work. Why? Just like a promise that was not resolved, this does not directly give us the value we need. ConstellationHttpCall('Sirius')
is an observable. We need to subscribe to it. Subscribing to the observable is like turning on your receiver to capture the transmissions coming from a spaceship.
Most web developers have had experience with using promises to deal with HTTP calls, but careful, they are not exactly the same as an observable. A promise represents a single future value, like receiving a package delivery. You receive the value once, while an observable represents a stream of values over time, akin to continuously receiving messages. Of course, an observable can also only send one value over time, but you still need to subscribe to it:
ConstellationHttpCall('Sirius').subscribe(searchResult => {
constellation = searchResult;
});
console.log(constellation)
// Canis Major
In that case, turning the observable into a promise might be a good idea… But let’s not get hasty!
🔄 Level 1: Observables, asynchronism & reactive programming
Now that we understand what an observable is, let’s move along. My friend and I need to know how many stars we should visit while discovering Sirius’ constellation. We’ll need another HTTP call to do that.
let constellationStarNumber: number;
ConstellationHttpCall('Sirius').subscribe(searchResult =>
constellation = searchResult
);
ConstellationInfoHttpCall(constellation).subscribe(constellationSearchResult =>
constellationStarNumber = constellationSearchResult.starNumber
);
console.log(constellationStarNumber);
//undefined
Oh no, the number of stars in the constellation is undefined! What happened?
The above code may seem logical at first glance, but it encounters a fundamental problem: asynchronism. Asynchronous operations like HTTP requests do not block the execution of subsequent code. Therefore, by the time the second HTTP call is made, the value of constellation
may not be available yet.
The way this code is written is based upon the assumption that the code is executed line by line, with a sequential execution of commands, which follows the imperative programming paradigm. In reactive programming, which is what observables and RxJS are based on, events act like ongoing events that we can keep an eye on. We’re able to tweak them, blend them together, and create brand-new streams of data. They work asynchronously: their effects might not be obvious to the untrained eye. We need to combine our two calls.
🧪 Level 2: How to combine two observables?
Now we know that we have to combine our observables. Also, my friend and I want information about more constellations than just Sirius, which is why we’re adding a search input in our web application, that’ll look for constellation info for any star.
My friend and I have had the most brilliant idea to join our observables:
let starName: string; // The parameter for our HTTP call
ConstellationHttpCall(starName).subscribe(searchResult => {
ConstellationInfoHttpCall(searchResult).subscribe(constellationSearchResult => {
constellationStarNumber = constellationSearchResult.starNumber;
});
});
This does work, but the shaman of our village says that if we use this to navigate in space, we might be in great danger!
Why? What we’re doing here is triggering a new subscription for every value emitted by the observable ConstellationHttpCall(starName)
. You cannot unsubscribe from the nested observables. If they are not managed properly, this can lead to a proliferation of unhandled observables and subscriptions, all lingering in memory, which causes memory leaks and therefore negatively impact performances (and impact our trip to space!).
Thus, while it’s essential to combine observables to obtain our desired information, it’s equally crucial to do so with the right operators. Think of them as functions that can be used to transform the data streams produced by observables. Using operators allows us to elegantly handle asynchronous operations and manage the flow of data without falling into the pitfalls of nested subscriptions. Popular operators are for instance switchMap
, mergeMap
or concatMap
.
- The switchMap operator transforms each value emitted by the source observable into a new inner observable, subscribes to it, and begins emitting the values from this inner observable. In our story, the source observable is
ConstellationHttpCall
, which we want to combine withConstellationInfoHttpCall
. This process is repeated for every value received from the source observable, creating a fresh inner observable each time. When generating a new inner observable,switchMap
automatically unsubscribes from any previously created inner observables, ensuring only the latest inner observable is active at any given time. It switches to the newly created inner observable, even if the previous inner observable has not finished emitting. - The mergeMap operator, like
switchMap
, transforms each value emitted by the source observable into a new inner observable. However, unlikeswitchMap
,mergeMap
allows for multiple inner subscriptions to be active at a time. It concurrently subscribes to all inner observables and emits values from them as they arrive. It merges all of the newly created inner observables. - Just like
mergeMap
, concatMap also transforms each value from the source observable into a new inner observable. However,concatMap
differs in its behavior regarding the subscription to inner observables. Instead of subscribing to all inner observables concurrently,concatMap
subscribes to them sequentially, one after the other. It waits for the previous inner observable to complete before subscribing to the next one. This ensures that the values are emitted in the same order as the source observable. It concatenates (links together in a series) the newly created inner observables.
Here we will use switchMap()
.
ConstellationHttpCall(starName).pipe(
switchMap(constellation => ConstellationInfoHttpCall(constellation))
).subscribe(constellationSearchResult => {
constellationStarNumber = constellationSearchResult.starNumber;
});
The pipe()
function is needed to chain multiple operators together.
Conclusion
From Level 0’s foundational understanding of subscribing to observables to Level 1’s realization of the asynchronous nature of HTTP calls, and finally, to Level 2’s quest for combining observables efficiently, we’ve encountered challenges and triumphs similar to navigating the stars themselves. Handling observables is tricky, but when keeping in mind the right logic and tools, it can become as easy as sailing in the Milky Way.