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 understand switchMap
and it's workings from the standpoint of events and handlers.
RxJS - noise = events
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' });
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, switchMap
ing 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?
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 })
);
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();
};
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.
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 is Musical
If Observables were audio, their overlap would look like this:
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.
Top comments (0)