loading...
Cover image for Never Use The Wrong RxJS Operator Again (by never using one again!)

Never Use The Wrong RxJS Operator Again (by never using one again!)

deanius profile image Dean Radcliffe ・5 min read

I was reading the fine post About SwitchMap and Friends by Jan-Niklas Wortmann. And this quote about switchMap reminded me how hard it is to understand Observables for beginners:

A higher-order operator is handling an Observable of Observables.

Perfectly clear right? At least its not as bad as this one from the switchMap documentation itself:

[switchMap] projects each source value to an Observable which is merged in the output Observable, emitting values only from the most recently projected Observable.

While all the descriptions of these and other RxJS operators are accurate, they fail to evoke a real feeling for when and why to use them. It is this reason that I made RxJS on-board-ability a central theme of my talk at RxJSLive 2019, and why I created the library polyrhythm to help get common Reactive/Observable tasks done more simply.

Let's look at what's really going on when you switchMap over an Observable of Observables.


Polyrhythm + RxJS = πŸ’œ

search gif

Searching β€” a search box with suggestions β€” is one of the most common uses for switchMap. You do an AJAX lookup on changes to the search input. Let's ignore debouncing for now, and say in non-technical language that you want to shutdown the old search (and its xhr) when the new one begins.

Here is polyrhtyhm code that makes the form run:

<input id="search-text" onchange="trigger('search/change')">

function ajaxToResult$({ payload: { text }})) => {
    return ajax$(`search?q=${text}`).pipe(tap(searchResult => {
        updateUI(searchResult);
    });
}

listen('search/change', ajaxToResult$, { mode: 'replace' });
Enter fullscreen mode Exit fullscreen mode

In response to DOM change events, we create events of type search/change, putting them onto an event bus with trigger. The function ajaxToResult$ returns an async Observable of 1) the xhr 2) a call to the updateUI function which does something with the result. This function is the same kind of function you'd pass to switchMap, except that it is expecting an event with type and payload fields.

This function ajaxToResult$ runs upon every event. But what if it's already running you ask? The mode 'replace' instructs the Listener to do what switchMap does, cancel the existing and start a new ajaxToResult Observable. The timing, and ultimate behavior is still as shown below, where you can see the "replace" occurring as the green-diamond-producer is replaced with a yellow diamond producer.

Observables - Same, Just Different

With an Observable-only implementation, the same pieces are there, but in a different combination.

First you have your search-change events as part of an Observable. Then you'll create the "outer" Observable, switchMaping to ajaxResults. Then you call subscribe.

const searchChange$ = fromEvent(searchText, "change");
const outer$ = searchChange$.pipe(switchMap(ajaxToResult$));
outer$.subscribe();
// TODO what's a better name for outer$ here?
Enter fullscreen mode Exit fullscreen mode

This code works, but I don't like a few things about its readability.

The concurrency operator is buried within a chain of code. And I don't like having to create, and thus name, and subscribe to the outer observable. Search changes and searches themselves being merged in one object feels like unnecessary coupling.

The polyrhtyhm version will pass the same unit tests, and run just as fast. Why impose a high burden of readability if you don't have to?

Triggerable

The great thing about listeners is they don't care from where their events comeβ€”this is a major form of decoupling.

Suppose I had my searchChange$ in an Observable already - I could fire them off as named events:

searchChange$.subscribe(({ target }) =>
  trigger("search/change", { text: target.value })
);
Enter fullscreen mode Exit fullscreen mode

And my listener would run the same. The listener is not tied up with the triggerer (the event producer).
Named events of your own design are the glue that hold your app together, not brittle coupling of JS objects, or reliance on any particular framework.

Decoupling, Separation of concerns

How many times have you changed an RxJS operator because you didn't choose the correct one on the first try? It happens to us all! Wouldn't it be nice if it were a) easier to change to the new one and b) more readable once you've changed it. No more sending your colleagues and yourself to the RxJS documentation when you can't remember if switchMap or exhaustMap is the one which replaces the old ajax. The word "replace" should be sufficient, hidden behind whatever constant you like, or chosen from the TypeScript enum.

Listeners are the logical unit to apply concurrency, and keep themselves decoupled from the Observable of triggering events. With polyrhythm you don't ever have an Observable of Observables, you have events and listeners. And it just works, and scales up to rather large apps with webs of dozens of events and listeners. It's in production, and tested, so use it if it makes sense for your team.

Conclusion

Using RxJS with its operators directly is not wrong, but if you can have clearer code by shredding outer Observables into events, and putting Listeners in charge of result mapping, then you're on easy street! Yes, I made that sentence sound ridiculous on purpose - but now, you understand it - AND the sentences I first mentioned above ;)

Dean


If you're still reading, these supplemental diagrams will help explain:

Async Is Just Math πŸ€“ (Combinatorics!)

I believe the concurrency modes offered by RxJS operators are a subset of a universal concept. Its as though inside of switchMap lives a reducer looking like this.

(oldSubscription, newObservable$) => {
  oldSubscription.unsubscribe();
  return newObservable$.subscribe();
};
Enter fullscreen mode Exit fullscreen mode

And each operator has a similar thing inside. Because there are 4 total combinations of whether you're "ending the old" or "starting the new", there are 4 RxJS operators, right? (Quiz: can you name them?)

Actually there are 5 possibilities shown below, and RxJS covers 4 of them.

async matrix rxjs

So of course I wrote and exported an operator from polyrhythm to fill this hole, called toggleMap. Not so much due to overwhelming demand, as for my own OCD for symmetry :)

async matrix

Async is Musical

If Observables were audio, their overlap would look like this:

Polyrhythm Concurrency Modes

When building UI, I find that 80% of user expectations can be fulfilled just by choosing the correct mode (another 10% with some debouncing thrown in there).

So Im happy to use Observables, and refer to these concurrency modes/operators by their Polyrhythm names, instead of their RxJS names. I'm happy for RxJS for having brought them to my attention, but I no longer thrill to see their names in my codebase.

Discussion

pic
Editor guide