Observables have really changed the way I think about JavaScript over the last few months. There's been a lot of hype around use cases for RxJS, but most of them are incomprehensible and dubiously useful. Here are the RxJS ideas that I'm truly thankful for.

Event Emitters and Node.js Streams Suck

Since the early days of JavaScript, we had the ability to register event handlers, like document.on('load'). The event emitter design pattern is ubiquitous, but has some major limitations. The biggest problem is that there's no way to get a stream of all events coming from an object. For the browser document object this isn't a big problem, but what about less-well-understood event emitters like a MongoDB replica set? Each event is its own special snowflake and needs its own handler. A casual user would need to carefully inspect the docs to figure out which events they care about, rather than simply getting a stream of all events and figuring out later what they want.

Node.js readable streams helped with this issue by reducing an event emitter to a few distinct events with well understood semantics, like:

  • 'data'
  • 'end'
  • 'error'

Streams made it much easier to reason about piecemeal data, but unfortunately they aren't really used as a replacement for conventional event emitters. The exception is the angular2 event emitter, which is basically a simpler stream. I think this is mostly because the Node.js stream API is clunky and was aimed at being a tool for performance (and thus only useful to 20% of developers 20% of the time) rather than an elegant tool for structuring code.

The biggest limitation of streams, though, was filtering and transforming. You could do things like .filter() and .map() on streams, but you had to write a class that would extend from the TransformStream class. Transform streams are clunky and ugly, mostly because they involve creating a new class and a new object. The API really harkens back to the good old days of Java when everything was a class and all projects where as comically bloated as Enterprise Quality Fizzbuzz. Pretty soon you'll start thinking that writing a TransformStreamAbstractFactoryProvider class is a good idea.

Observables take the streams API to its logical conclusion. Transforming observables is neat and simple with ES6 arrow functions:

  • Merge 2 observables? o3 = Observable.merge(o1, o2)
  • Filter out certain values? o2 = o1.filter(obj => obj.enabled)
  • Transform? o2 = o1.map(obj => obj.name)

What are Transformable Streams Good For?

One of my favorite use cases for observables is promise rejection handling. Sounds crazy, I know, but bear with me. How do you usually handle promise rejections? If you're like I was this time last year, you do something like this:

// One file
module.exports = function save(user) {
  return co(function*() {
    yield db.collection('User').updateOne({ _id: user._id }, {
      $set: user
    })
  });
};

// Another file
save(user).catch(handleRejection);

You probably have a nice handleRejection function that handles your promise rejections, say by responding with an HTTP error code and reporting an error to sentry. The problem here is that you now have a many-to-one relationship between promises and promise error handlers. handleRejection() is now in danger of becoming a god function because it'll serve as an entry point for all error handling.

Now suppose you have an observable that spits out promises.

const result$ = new Observable(observer => {
  app.put('/user', (req, res) => {
    observer.next({ req, res, promise: save(user) });
  })
}).share();

result$.subscribe(obj => {
  obj.promise.catch(error => {
    obj.res.send('Woops, error occurred', error);
  });
});

result$.subscribe(obj => {
  obj.promise.then(res => {
    obj.res.send('Success', res);
  });
});

Now, there is no one function that you need to put everywhere to handle promise rejections. As long as the promise goes into the observable, it'll get a rejection handler. You can also filter and map your observables, so you can easily do things like say "only errors from these endpoints go into sentry" without needing to put extra bloat into a central error handling function.

Conclusion

Using RxJS on the server side has been the single biggest improvement we've made to our API at Booster over the last couple months. We use it with co for promise rejection handling, we use it for slack and SMS notifications, and we're using it for more and more every day. I think Andre Staltz really summed it up when he described observables as making code "globally understandable." The promise rejection handling is a great example: you have some locally understandable code in co, and you have a higher-level view on top of the co logic that sees a stream of promises. By piping the results of individual business logic operations into an observable, you have the ability to interact with a forest rather than individual trees.

Want to learn more about co and why it forms the backbone for our business logic? Check out my ebook, The 80/20 Guide to ES2015 Generators. It walks you through creating your own implementation of co in the first 25 pages.

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus