Skip to content

goodguydaniel.com

Reactive Programming: The Good and the Bad

Reactive Series - Part 4

JavaScript, Reactive Programming8 min read


After seeing reactive streams in action with RxJS, I would now like to expand "Part 1 - Why you should consider Reactive Programming" by debating some of the gains & pains of reactive streams and RxJS. As of now, we've covered some fundamental aspects of reactive programming, and we've seen some code as well! In this part of the series, I'll be compacting some of the benefits I felt while using reactive programming and some of the major pain points of adopting it. Since we've used RxJS, I'll make occasional mentions of this reactive library's ups and downs.

The good

Let's explore some beneficial aspects of adopting reactive programming and RxJS now that we have looked at how to approach a problem and model it with streams.

Your code becomes inheritably lazy

Working with streams means nothing happens until you subscribe to them, which is terrific! Because any code path now has a "free of charge" stop & play capability! Without any effort, your computations become lazy by default executing when needed - Promises are eager. They start running as soon as you construct them. It's common to wrap a Promise with a function to make it lazy.

Your code is likely to be more concise

As we've seen in the hands-on part of this series, you'll likely have to model events and think about what parties are interested in consuming such events. You'll spend less time focusing on the implementation details. You'll rather spend your time drawing a mental map of the events' interdependency that define your business logic. In my experience, this often results in more compact implementations, making it more accessible to capture the big picture of the internal flows in your application.

You're likely to write less code

It only makes sense that if you have an ecosystem, such as RxJS, in your arsenal, you'll spend less time implementing behaviors that are already built-in in some operator (e.g., debouncing, throttling, etc.). Just like you'll spend less time mutating the DOM directly when you use a UI framework such as React or Vue, you'll think more about how your UI looks like and what data binds to what parts of your UI.

Effortless cancellation

In my opinion, an overlooked easy win of coding with streams and RxJS is how easy it becomes to implement cancellation. What kind of behavior does cancellation concern, you ask? Let's see an example.

source: © 2021 by goodguydaniel.com

In the above GIF, we have a list of items. Hovering on each item in the list triggers an ajax request to fetch some data from the server. The problem of eagerly (compared with clicking the item, for instance) triggering the requests upon mouse hovering is that you can potentially fetch the data for all the items but ending up not displaying any of the data to the end-user. To avoid that, we cancel an item's request when the user's mouse leaves it.

(Note: oh, btw on the above GIF, I'm not calling a real API, I'm just using the tweak browser extension to mock the HTTP requests seamlessly)

Here's the snippet of the above pattern, implemented with switchMap and takeUntil.

1const listEl = document.getElementById("list");
2const resEl = document.getElementById("res");
3const fetchById = (id) => ajax(`https://some-service.com/api/items/${id}`).pipe(map((r) => r.response));
4const mouseOutItem$ = fromEvent(listEl, "mouseout");
5const mouseOverItem$ = fromEvent(listEl, "mouseover")
6 .pipe(
7 switchMap((event) => {
8 const id = event.target.id;
9 resEl.innerHTML = `loading item ${id}...`;
10 console.log(`Fetching data for item ${id}...`);
11 return fetchById(id);
12 }),
13 map((response) => {
14 resEl.innerHTML = JSON.stringify(response, null, 2);
15 console.log(`Done fetching data for item ${response.item.id}!`);
16 }),
17 takeUntil(
18 mouseOutItem$.pipe(
19 tap(() => {
20 console.log(`❌ Cancelled fetch data for item ${event.target.id}!`);
21 resEl.innerHTML = "...";
22 }),
23 ),
24 ),
25 repeat(),
26 )
27 .subscribe();

Expand to see example full code
1<!DOCTYPE html>
2
3<head>
4 <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.5.5/rxjs.umd.js"></script>
5 <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.5.5/rxjs.umd.js.map"></script>
6 <style>
7 li {
8 padding: 4px;
9 border: 1px dashed green;
10 color: black;
11 }
12
13 li:hover {
14 background-color: lightblue;
15 color: blueviolet;
16 cursor: pointer;
17 font-weight: bold;
18 }
19 </style>
20</head>
21
22<body>
23 <div id="app"></div>
24 <script>
25 const {
26 delay,
27 filter,
28 map,
29 mapTo,
30 switchMap,
31 takeUntil,
32 takeWhile,
33 tap,
34 withLatestFrom,
35 repeat,
36 } = window.rxjs.operators;
37 const {
38 fromEvent,
39 merge,
40 timer,
41 } = window.rxjs;
42 const ajax = window.rxjs.ajax.ajax;
43
44 // preventing the default event on dragover
45 // so that we're allowed to drop an element
46 fromEvent(document, "dragover")
47 .pipe(tap(event => event.preventDefault()))
48 .subscribe();
49
50 // all the game markup goes here
51 document.getElementById("app").innerHTML = `
52 <h2>Hover on the items to fetch some async data</h2>
53 <ul id="list">
54 <li id="item-1">item 1 (mouse over to fetch data)</li>
55 <li id="item-2">item 2 (mouse over to fetch data)</li>
56 <li id="item-3">item 3 (mouse over to fetch data)</li>
57 <li id="item-4">item 4 (mouse over to fetch data)</li>
58 </ul>
59 <h3>Item detail</h3>
60 <pre id="res">...</pre>
61 `;
62
63 const listEl = document.getElementById('list');
64 const resEl = document.getElementById('res');
65 const fetchById = id => ajax(`https://some-service.com/api/items/${id}`).pipe(
66 map(r => r.response),
67 );
68 const mouseOutItem$ = fromEvent(listEl, 'mouseout');
69 const mouseOverItem$ = fromEvent(listEl, 'mouseover').pipe(
70 switchMap(event => {
71 const id = event.target.id;
72 resEl.innerHTML = `loading item ${id}...`;
73 console.log(`Fetching data for item ${id}...`);
74 return fetchById(id);
75 }),
76 map(response => {
77 resEl.innerHTML = JSON.stringify(response, null, 2);
78 console.log(`Done fetching data for item ${response.item.id}!`);
79 }),
80 takeUntil(mouseOutItem$.pipe(tap(() => {
81 console.log(`❌ Cancelled fetch data for item ${event.target.id}!`);
82 resEl.innerHTML = '...';
83 }))),
84 repeat(),
85 ).subscribe();
86 </script>
87</body>


Easier to deal with backpressure

"Backpressure is when the progress of turning that input to output is resisted in some way. In most cases that resistance is computational speed", from "Backpressure explained — the resisted flow of data through software"

Did it ever happen that your addEventListener handle is just executing too many times? For instance, while running an event handler for the scroll event? It's common to use a debounce strategy to run your event handler only after X time has passed since the last scroll event. Again, with built-in operators such as debounce in RxJS, you can quickly relieve the load in your consumer functions (event handlers). The operator single-handedly takes care of debouncing emissions for you!

Converging towards a single style of coding

I've covered this in greater detail during "Part 1 - Why you should consider Reactive Programming" the gist is that working with streams when done right, might make callbacks, Promises, and async/await nearly obsolete. I say nearly because you' might still use Promises under the hood, but your code now can look the same whether you're tackling synchronous or asynchronous tasks.

Complicated things made easy

I want to reinforce that you can leverage reactive libraries such as RxJS to solve complex problems with little effort. If you haven't watched the talk "Complex features made easy with RxJS" by Ben Lesh, I would highly recommend it. In the first 10 minutes, you'll see how easy it becomes to tackle some of the following problems with the help of RxJS:

  • Basic drag & drop implementation [5:54]
  • Avoid double submission through a button that involves an asynchronous Ajax call [7:04]
  • Throttle autosuggestion field that involves fetching data with a given search term [7:24]

If you aim to solve one of the problems mentioned in the above list, you can avoid reinventing the wheel by following the reactive patterns demonstrated in the video.

Maintainability

There are two aspects of maintainability that I would point out. First, you'll write less code; there's a lot of heavy lifting that RxJS can do for you; if you leverage that, you'll undoubtedly ending writing less code. The second is that a combination of streams with powerful operators solve complex problems in a very declarative manner. Code will look concise and straightforward; instead of verbose branching and imperative logic, you'll have a few combinations of operators that will do all the magic for you. However, with RxJS and streams, maintainability is a double-edged sword. We're going to cover that in the next part of this article.

The bad

Let's look at the not so bright side.

Learning curve

Any modern JavaScript codebase uses a cocktail of libraries. RxJS (or whatever library you would adopt) would be just one more thing you'll have to teach newcomers. But don't take this as a light decision. You're not only introducing a new library in the codebase, but you're also introducing a new paradigm. I feel there's quite a learning curve towards mastering reactive programming and RxJS. Not only you're exposed to an entirely new ecosystem with new APIs, but you also need the time to process this different paradigm of programming with streams. As we've experienced in previous articles, it can be quite different from a traditional writing code style (compared with imperative programming, for example).

Depending on how broadly you'll embrace this paradigm, you might need to ship new tooling for unit testing and master additional concepts such as marble diagrams - which helps you properly model and assert data streams. However, it might require you and your team to learn another tricky DSL (domain-specific language) to deal with these diagrams.

Some might argue that the "learning curve" is a "one-time cost". As it turns out, in the software industry, software engineers tend to move a lot (due to the high demand for the skill these days), and they might be sticking around in the same company for an average of 2 to 3 years before embracing a new challenge. The bustling job market makes me believe that it is not wise to think of the "learning curve" as a one time cost because soon, your freshly trained engineer with RxJS skills might say goodbye.

Debugging does not get any easier

Just the same way, you can split your code into several functions, and those functions call other functions which, without some structure, might end up in spaghetti code. Likewise, you can end up entangled in a spaghetti of streams and not know which way to turn. I think simple functions are usually more straightforward to debug, given that they are composed sequentially. You're reading a function; that function might call other N functions and so forth. Well, with streams, it might not be that candid because there's no such thing as a stream invoking another stream. Instead, you'll have a stream plugging with other streams in mixed ways depending on the operators that join them. It might feel overweighing at some point to find your way around some particular flow (hopefully, you won't' reach that point because your code is clear and concise).

Another aspect that you might run into while debugging streams is that such an amount of abstractions and compact implementation will let no space for you to plug into a stream and debug it or inspect it the same way you debug a function. Things tend to be on a higher level of abstraction - which is beneficial because it will free up your mind on some implementation details. Hence when it comes to the point you need to dig further down, understanding what's going on at the very core of your flow, you might need to tap into streams here and there to figure out what's the issue. Although old this article, it might be one of the best walkthroughs of the problem I'm trying to surface here. It will give you a solid strategy to debug streams (even if you're looking at that particular code for the first time) - tip for reading it: mentally replace do per tap.

Human creativity

Kiss: Keep it Simple, Stupid, not easy with reactive streams and RxJS tough. I'll tell from personal experience that it will come the day you'll look at your code, and although it's just fine, you'll feel this voice inside your head: "Are you sure there's nothing else much fancier in the RxJS API that would allow you to write less two lines of code?" - fight that voice! Creative solutions with RxJS might often result in dreadful consequences for your product and team. But yet, RxJS has such a unique and evolving ecosystem that will feel tempting with time to start to chime in some new operators just for the sake of adding more operators. Fight that in code reviews - this is the place you can understand why your colleague is shipping that new exotic operator and challenge simpler alternatives already in use. I guess this is general advice. I wouldn't apply it to reactive programming only. If you don't have a code review process, ¯\_(ツ)_/¯.

With great power

As usual, with powerful tools, there is increased responsibility towards their use. If you pick RxJS as your weapon of choice, there are things you'll need to be extra careful. I want to highlight one: shareReplay. We didn't look into this operator in particular during this series, but we did learn the difference between Hot and Cold observables, a quick refresher:

  • Hot Observables - multicast; all subscribers get data from the same producer (e.g., a live music concert in a stadium).
  • Cold Observables - unicast; each subscriber gets data from different producers (e.g., a show on Netflix).
shareReplay allows you to take a cold observable and make it hot in the sense that you can now multicast the underlying computation to multiple subscribers 🤯 - that's share. With this operator, new subscribers will be able to "catch up" with previously emitted values at any point in time - that's replay.

Don't want to get into extensive detail of what the operator pertains to; be mindful of your implementation gaps that might trigger massive memory leaks in your application by using this operator. The gist is that using shareReply without refCount may origin a memory leak because the operator doesn't automatically close the stream after all its consumers' have unsubscribed.

Also, historically, there have been some issues around its implementation [1] [2].

Error handling

Finally, yet notably, error handling. Something I did not cover in the previous articles. Error handling is yet something else that changes considerably. Let's look at a simple example comparing reactive vs. non-reactive.

try...catch
1switchMap(event => {
2 try {
3 const id = event.target.id;
4 resEl.innerHTML = `loading item ${id}...`;
5 console.log(`Fetching data for item ${id}...`);
6 return fetchById(id);
7 } catch (error) {
8 // that's not how this works with streams...
9 }
10}),
11map(response => {
12 resEl.innerHTML = JSON.stringify(response, null, 2);
13 console.log(`Done fetching data for item ${response.item.id}!`);
14}),
catchError
1switchMap(event => {
2 const id = event.target.id;
3 resEl.innerHTML = `loading item ${id}...`;
4 console.log(`Fetching data for item ${id}...`);
5 return fetchById(id);
6}),
7catchError(error => {
8 if (error) {
9 console.error(error);
10 }
11 // fallback to an empty item and a message
12 // we need to return an observable!
13 return of({
14 item: {},
15 message: 'something went wrong',
16 });
17}),
18map(response => {
19 resEl.innerHTML = JSON.stringify(response, null, 2);
20 console.log(`Done fetching data for item ${response.item.id}!`);
21}),

For a more natural integration, you'll have to stick with the catchError operator. As a beginner, I would tend to wrap stuff around with try/catch, but things work slightly differently with streams. Observable is our primitive here, remember? Something mentioned as a "gotcha" of RxJS is the fact that an RxJS Observable does not "trap" errors; when an error bubbles to the end of the observer chain, when unhandled, the error, it will be re-thrown.

Closing notes

Would I use reactive programming and RxJS in my next project? Depends... There's a great deal of learning involved in using these technologies, so even though I might not always use them, I'm confident that these skills will (and are!) playing an essential role in my evolution as a software engineer. Just know that I would do it again (all the learning and writing).

I do think Reactive is powerful and useful, but it's not a holy grail that will solve all your problems overnight.

Please focus on the problem first, scrutinize the use cases, and Reactive shall reveal itself a solution.

I hope you've enjoyed this series is now approaching its end. This series is far from the perfect learning resource, but I hope that my perspective and way of explaining things fit some of you, complementing your learnings in a way. To close, I'll leave you with a list of fantastic learning resources for reactive programming and RxJS.



If you liked this article, consider sharing (tweeting) it to your followers.