The Dangers of (non)Completing Observables (and How to Develop by Concept)

Recently I've spent quite some time figuring out, why a part of our Angular app stopped working, although we didn't touch that part even a bit. The culprit of our issues turned out to be the complicated concepts of RxJS. You see, RxJS is an awesome library that brings the ideas of the publish-subscribe pattern and functional programming to JavaScript in a well-defined manner. This library took a decision to merge the concepts of a future (or a promise), an announcer, and a stream into a single concept called observable. I guess this reduced the amount of code needed for the underlying implementation but at the same time, it increased the complexity of the library usage. While I'm not sure that it's a good decision, I'm also not saying that it's a bad one. One thing that I'm certain about is that there are many pitfalls around RxJS, and the more stories like this you read, the more mistakes you'll be able to avoid.

The Setup

In essence, we had a component with an NgRX store (yes, I hate NgRX and I have good reasons for that, but this post will not touch that topic at all). To initialize the store with the data that could be passed with the URI, we used a route resolver:

{
    path: 'dashboard',
    componenet: DashboardComponent,
    resolve: {
        dashboard:  DashboardResolver
    }
}

In turn, the DashboardResolver will resolve the dashboard state from the URI in the following manner:

public resolve(route: ActivatedRouteSnapshot) {
    const queryParams = route.queryParams;
    return forkJoin(
        this.settingsService.mapUriToSettings(queryParams.widgetA),
        this.settingsService.mapUriToSettings(queryParams.widgetB),
        this.settingsService.mapUriToSettings(queryParams.widgetC)
    ).pipe(
        tap(([settingA, settingB, settingC]) => {
            this.setDashboardInitialState({
                widgetA: settingA,
                widgetB: settingB,
                widgetC: settingC
            });
        })
    );
}

If you are truly interested in this approach, you can read the Router Resolver section of the Where to initiate data load in NgRx blog post.

What is important to understand here from the upper example, is that we need to map the values present in the URI into some dedicated settings with a help of mapUriToSettings. In turn, the last-mentioned method relies on other data to be loaded, so it returns an observable. Thus we need to ensure that all the three observables return data with forkJoin and combine the results into a single "state" object afterward.

The Problem

If you know RxJS well, you may have already spotted the problem. I would still emphasize, that in my opinion, the root cause lies in the complexity that this library brings to the concept of an observable. Instead of just emitting values as a standard announcer, the RxJS observable also has the functionality of a stream, from which you can read values until it reaches the end. Thus the observable has three possible events: 1) a next value is emitted, 2) an error occurs, 3) the observable completes (i.e., it reached the end, and will not emit anything else).

And it turns out, that forkJoin waits for all the observables to complete. And while in the past the mapUriToSettings method's observable would always complete after the first emission, due to some changes, it stays active in the current version of our project. As the result, the forkJoin never emits any value, and the state never receives the values from the URI.

Imagine, if observables were just things that can emit values, and not something that completes or not. The error would never happen. Now, would there be other problems if we had separate observable and stream concepts instead of one? I don't know, maybe.

Development by Concept

I don't know why this happened. Maybe the developer of the original code didn't know how forkJoin works. And in my opinion, it's ok. The function's name conveys that you fork the execution and you join the results. There is no hint, that the execution has to "complete" in a special way.

On the other hand, there is a chance that the original developer knew how forkJoin should be used, but still decided to stick with it, because "it just works." Here we arrive at the point, where I start advocating the "Develop by Concept" approach.

While I was studying at a university, I was lucky enough to attend a course about the Prolog programming language. Many people would argue that this is not really a programming language, because you don't tell a computer exactly what to do. This is true, and this is the most amazing thing ever. With Prolog, you don't tell how quicksort works; instead, you specify what is an array sorted with quicksort. Now I try to follow this approach in any programming language.

Let's ask ourselves what is the resolve method all about. Well, it resolved some text from the URI into something that our state understands. To do that, it uses mappers. These mappers return us observables. Do these observables complete? Does that even matter? All we need to know is that these observables should emit at least one value because without it we can't resolve the URI. And there is an RxJS function that fits our needs perfectly: combineLatest. This function would wait for each observable to emit at least one value; from that moment it will emit combined values with each new emission.

Are we good now? Well, almost. It's good to use combineLatest because we don't care if the underlying observables completed, or if they are going to complete due to some changes that may happen in a year. But with combineLatest we conceptually say: we will get tree observables by mapping URI values, and each time these observables emit a value we will update the state with the new values. And this is not what resolve is about. It's not about tracking changes and updating the state. So to avoid this constant stream, we can add the first function to the pipe, which will take only the first emitted combination of settings.

return combineLatest(
    // mapping observable 1,
    // mapping observable 2,
    // mapping observable 3,
).pipe(
    first(),
    tap(([settingA, settingB, settingC]) => {
        // set state with 3 mapped settings
    })
);

Definitely, this is a trivial bug, and I fix many of these every day. But I wanted to use this story to share my way of thinking by concept when writing code. And also to point out the issues of RxJS of course 😉

No Comments Yet