DEV Community

loading...

Vanilla JavaScript vs. RxJs

riccardoodone profile image Riccardo Odone Originally published at odone.io ・4 min read

You can keep reading here or jump to my blog to get the full experience, including the wonderful pink, blue and white palette.


This post compares vanilla JavaScript with RxJs. My intent is not to demonstrate whether one or the other is the best approach. As always, it depends.

But I want to highlight the importance of tackling a problem from different angles. In this case, it's imperative against declarative, or "push" vs. "pull."

Also, different mental models provide insights that can be exploited in the solution, regardless of the paradigm chosen. In this article, the imperative approach helps exploring the problem, the declarative one distills the solution: both have their merits.

It's Monday Morning

While you wait for the browser to load the to-dos, you wonder about the feature you will be working on today.

Maybe you will work in Elm-land where run-time exceptions never show up, or you will be modeling new domains in Haskell where impossible states don't compile.

Nope, it's JavaScript. You need to add an input field to enable users to fetch data.

Damn.

You believe in small steps and short feedback loops, so this is your first move:

<input type="text" id="query" />
Enter fullscreen mode Exit fullscreen mode
const callback = value => console.log(value)

const queryElement = document.getElementById("query")
queryElement.addEventListener('input', event => callback(event.target.value))
Enter fullscreen mode Exit fullscreen mode

A glance at the browser confirms that typing in the input field logs the value in the console. Great!

Time to fetch:

-const callback = value => console.log(value)
+const callback = query =>
+  fetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)
+    .then(response => response.json())
+    .then(response => console.log(response))
Enter fullscreen mode Exit fullscreen mode

Another quick manual test confirms that the requests work.

You spend the rest of the day making things pretty and replacing the console.log() with the appropriate function to fill the DOM. Then, you move the ticket to done full of pride.

That was slick!

Unfortunately, the next day you get an email from the devops team with the following subject: URGENT!1!. After your deploy, servers started receiving a ton of requests.

You open the application and type "holy moly!" in the text field. Your heart skips a bit when you notice it generated 10 network requests:

  • "h"
  • "ho"
  • "hol"
  • "holy"
  • "holy "
  • "holy m"
  • ...

Holy moly! indeed, I forgot to debounce!

+const DEBOUNCE_MILLISECONDS = 300
+let scheduled
+
 const callback = query =>
   fetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)
     .then(response => response.json())
     .then(response => console.log(response))

+const debounce = fnc => arg => {
+  clearTimeout(scheduled)
+  scheduled = setTimeout(() => fnc(arg), DEBOUNCE_MILLISECONDS)
+}
+
+const debouncedCallback = debounce(callback)
+
 const queryElement = document.getElementById("query")
-queryElement.addEventListener('input', event => callback(event.target.value))
+queryElement.addEventListener('input', event => debouncedCallback(event.target.value))
Enter fullscreen mode Exit fullscreen mode

To make sure not to piss the ops team again, you get deeper into manual testing. The debouncing works, but there is something strange: sometimes, the application displays data for an old query.

Aha, the responses are coming out of order.

To make it more visible you introduce a random delay in the fetch:

+const throttledFetch = (url, options) => {
+  return new Promise((res, rej) => {
+    const throttleBy = Math.random() * 10000
+    console.log(`throttledBy ${throttleBy} milliseconds`)
+    fetch(url)
+      .then(x => setTimeout(() => res(x), throttleBy))
+      .catch(x => setTimeout(() => rej(x), throttleBy))
+  })
+}
+
 const callback = query =>
-  fetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)
+  throttledFetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)
     .then(response => response.json())
     .then(response => console.log(response))
Enter fullscreen mode Exit fullscreen mode

Luckily, you can abort the previous fetch before executing the next one:

+let controller = new AbortController()

 const throttledFetch = (url, options) => {
   return new Promise((res, rej) => {
     const throttleBy = Math.random() * 10000
     console.log(`throttleBy ${throttleBy} milliseconds`)
-    fetch(url)
+    controller.abort()
+    controller = new AbortController()
+    fetch(url, { signal: controller.signal })
Enter fullscreen mode Exit fullscreen mode

It's almost the end of the day, and you are staring at this code:

const DEBOUNCE_MILLISECONDS = 300
let scheduled
let controller = new AbortController()

const throttledFetch = (url, options) => {
  return new Promise((res, rej) => {
    const throttleBy = Math.random() * 10000
    console.log(`throttleBy ${throttleBy} milliseconds`)
    controller.abort()
    controller = new AbortController()
    fetch(url, { signal: controller.signal })
      .then(x => setTimeout(() => res(x), throttleBy))
      .catch(x => setTimeout(() => rej(x), throttleBy))
  })
}

const callback = query =>
  throttledFetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)
    .then(response => response.json())
    .then(response => console.log(response))
    .catch(error => console.log(error))

const debounce = fnc => arg => {
  clearTimeout(scheduled)
  scheduled = setTimeout(() => fnc(arg), DEBOUNCE_MILLISECONDS)
}

const debouncedCallback = debounce(callback)

const queryElement = document.getElementById("query")
queryElement.addEventListener("input", event => debouncedCallback(event.target.value))
Enter fullscreen mode Exit fullscreen mode

The throttling code needs to be removed. Still, the software crafter inside your head is in pain. You shouldn't have to tell JavaScript what to do line by line.

Instead of "pushing" information around, you want to "pull" and react to it. It should be as declarative as a spreadsheet.

It's too late to conjure that thought, your fingers are already typing yarn add rxjs:

const queryElement = document.getElementById("query")

fromEvent(queryElement, 'input').pipe(
  debounceTime(300),
  map(event => event.target.value),
  switchMap(query => fromFetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)),
  flatMap(response => response.json()),
  catchError(error => console.log(error))
)
.subscribe(response => console.log(response))
Enter fullscreen mode Exit fullscreen mode

Not only this achieves the same result, but also it's shorter and declarative. Not to count the additional insight you notice from this new angle:

const queryElement = document.getElementById("query")

fromEvent(queryElement, 'input').pipe(
  debounceTime(300),
  map(event => event.target.value),
+ distinctUntilChanged(),
  switchMap(query => fromFetch(`https://httpbin.org/get?query=${encodeURIComponent(query)}`)),
  flatMap(response => response.json()),
  catchError(error => console.log(error))
)
.subscribe(response => console.log(response))
Enter fullscreen mode Exit fullscreen mode

You make sure nobody else is looking, you sneak in the additional dependency, and you deploy.

Now, it's the end of the day!


Get the latest content via email from me personally. Reply with your thoughts. Let's learn from each other. Subscribe to my PinkLetter!

Discussion (6)

pic
Editor guide
Collapse
bias profile image
Tobias Nickel

why is there .subscribe? how many responses do you expect? are they so many that they need to get mapped? where would you put a breakpoint in the declaritive code?

Collapse
riccardoodone profile image
Riccardo Odone Author • Edited

Hello Tobias, let's see if I can help with your questions.

why is there .subscribe?

In RxJs you need to subscribe to the observable to consume the values it produces.

how many responses do you expect?

An observable is a stream of values, it's like an array that grows over-time. For example, fromEvent(element, 'input').subscribe(x => console.log(x)) logs every time the input event is fired by element.

are they so many that they need to get mapped?

Yes, it's a stream of values.

where would you put a breakpoint in the declaritive code?

You can use tap as Felix mentioned.

RxJs is both powerful and complex. It took me a long time to learn the basics, and I still make many mistakes with it. But I believe it's worth learning because it really gives a different perspective.

Collapse
bias profile image
Tobias Nickel

thanks, now you have described the api to me. I see, that in this style of code, everything is treated as a stream. However, an api request, a call via fetch, is fundamentally a single in and a single out. there is no stream of responses. The stream will always only have a single response or a single error. that is what async/await is about, and you get nice meaningfull stacktraces for debugging.

Thread Thread
riccardoodone profile image
Riccardo Odone Author • Edited

However, an api request, a call via fetch, is fundamentally a single in and a single out.

It doesn't have to be, it can be a response stream:

// https://web.dev/fetch-upload-streaming/

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');
Enter fullscreen mode Exit fullscreen mode

However, I agree most of the time it's not used that way. Still, treating everything as a stream has the advantage of making things composable and re-usable.

In any case, I'm not advocating for any approach. I should have made it more clear in the post. Also, you are right, I could have used async/await in the vanilla JavaScript code to make it look cleaner.

Collapse
leobm profile image
Felix Wittmann • Edited

you can add a tap function?

Edit:
The fromFetch function, which I think creates a complete event on the observable after the fetch? But I have not used it myself yet. It's been a while since I used RXJS regularly. And otherwise rather directly from Angular. There you normally use the HttpClient from angular.

Edit2:
oh, and if I see it right, is the subscribe not on the fromEvent observable?

Collapse
riccardoodone profile image
Riccardo Odone Author

Hey Felix. I'm not sure I understand the comment.

As long as I see it, fromFetch wraps the native fetch in an observable that handles cancellations for you.

The subscribe is bound to the entire observable that precedes it (after the fromFetch some additional transformations are applied).

Having said that, I'm not on expert of RxJs. For example, I'm not totally sure if the flatMap shouldn't have been a switchMap. I guess, in general, it's safer to use switchMap as a default when only one inner subscription should be active at once.