Hacker News new | ask | show | jobs
by Mitranim 3241 days ago
Good suggestion, should probably address this in the readme.

Short answer: observables do too much, and Rx is WAY, WAAAAY too large. We need simple async primitives before layering bigger abstractions on them. They shouldn't take encyclopedic amount of reading to learn. They need to come in a library that doesn't weigh over 100 KB.

5 comments

Agreed, thats why there are proposals of introducing Observables as ES8 native datatypes. Note that while full RxJS is heavy, Observables are not. Its the multitude of operators to compose observables, and the various helper implementations of Subjects (AsyncSubject, ReplaySubject, BehaviorSubject etc.) - which are similar to Defereds in Promise world - that make RxJS so heavy. If you use only the Observable part with a slim selection of operators in your code, you will not add so much of the library to your code (but granted the patch-based API of RxJS is not really trivial).
I believe we need a hierarchy of primitives:

Level 0: one-value operations (promises = futures).

Level 1: multi-value operations (streams = observables), implemented in terms of one-value operations. Tokio did it well for Rust (https://tokio.rs).

Starting at level 1 doesn't feel right to me.

I'd rather have a clear separation of data structures and implemenations:

Promise, Future, Observable = data structures

Bluebird, Fluture, RxJS Observable = implementation of the data structures

Operators (map/race/all) = helper implementations for operating the data structure implementations

Now, different implementations may make different choices, like there are dozens of Promise implementations tuned for either feature richness, speed, or small file size. Same holds true for observables, there are feature-rich libs like RxJS, but also fast and/or small implementations (Bacon, most.js, xstream).

My point: A cancelable promise is basically an observable that emits a single item (and caches that item). So an observable is kind of like a superset of a cancelable promise (and of course a non-cancelable promise). All operator implementations manipulating observables can directly be reused for cancelable and non cancelable promises-like observables; the other way around does NOT hold true.

My main point: Just use observables, they can do everything that promises and cancelable promises can do and, if needed, a lot more. So instead of layering, learn to use this one data structure and you will be able to cope with a lot of async troubles.

The main point is correct.

But observables are very different from promises at the core. Not only are they cached, but they're also eagers, while observables are not. The very minimum implementation of an observable is just a few lines of code. Promises...are much more complex. So I'm not sure how reusing operators between the two efficiently would work.

So yeah, just use observables. Most people think observables are a more complex, higher level abstraction with promises being the primitive, simple one. It isn't true though. Observables are much, MUCH simpler, and are perfectly suited to doing everything promises do, but better (lazy, optionally cached async primitive is way better).

You can make any observable "cached" in RxJS by subscribing it to a ReplaySubject or by calling .publishReplay(N).refCount() on them. So observables can be cached or uncached, and the cache length can be specified (N parameter). They can also be eager or lazy; that is the hot/cold lingo in RxJS world (or unicast/multicast).

I repeat my claim: Observables (at least as implemented by RxJS) are a superset of (cancelable-)promises. Superset means, they can do everything the exact same way as promises, and a lot more.

Yeah I know. I explained myself poorly. The observable itself is lazy and cold. Wrappers functions, observers (like subjects) and operators can add a higher level abstraction to change that. It's easy to make lazy into eager and cold into hot, of course, but the observable itself is cold/lazy.

Observables can do everything promises can do, but the construct itself, while more flexible, is significantly simpler. Like, -way- simpler. And a lot of what they do and how they do it comes from how they're implemented at the very base (essentially a generic observer pattern). Building observables on top of something else would add tons of overhead/complexity for no reason.

The observable needs to be the lowest level piece and we build the rest on top, not the other way around. That is counter intuitive since they can do so much more: the abstractions on top are more like specializations of the low level construct.

No disagreement here. Rx observables are strictly more powerful than promises/futures/streams.

But why start with the superset? I don't think it's good design. Consider the perspective of a language designer. You want to start with primitives that are as simple as possible while satisfying a vast number of real use cases. One-short async primitives are an important intermediary step towards observables, it shouldn't be skipped. Observables should be implemented in terms of these.

This layered design gets you a smaller cognitive cost of entry (promises are hard enough as it is!) and higher efficiency for the vast number of cases that don't need stream-like functionality.

On top of that, one-shot primitives are conceptually compatible with blocking expressions in coroutines such as async/await, whereas streams are not.

I don't get it when people shoot for fat primitives that do it all.

Okay. Most.js then, at 10kb minified looking at the build on unpkg.

Also, implementing an observable from scratch is pretty much trivial. The operators are where all of the logic are, and they are arguably simpler to understand than alternatives because of how well documented they are. And if you limit yourself to the operators that are equivalent to what this library provides, there really wouldn't be much complexity to it.

In my experience, most async operations fit very well within the small feature set of promises/futures, with only a few operators (map/all/race). Streams should be the optional next layer of abstraction, not the only layer of abstraction. They should be built on top of futures. I quite like how Tokio did it for Rust futures. [1] Not a fan of Rx and similar designs.

There are also different types of observables to consider. Rx, xtream, Most.js, they're basically stream libraries. When doing GUI programming, it's more useful to have reactive units with a _synchronous_ data access, because it matches how you want to access the data when drawing your view (e.g. in React). I ended using observables that look like Clojure's atoms (see Espo: [2]), with a special adapter for React (see Prax: [3]). Observable libraries like Rx were useless for that use case.

[1] Example of decent future/stream design: https://tokio.rs

[2] Observables focused on synchronous access: https://mitranim.com/espo/#-atom-value-

[3] React adapter for implicit reactivity, based on synchronous observables (impossible with async streams): https://mitranim.com/prax/

What about xstream? https://github.com/staltz/xstream it's a lightweigth alternative with a fraction of Rx's operators and a fraction of its weight.
I guess this needs a bigger answer.

A well-designed tool should minimise the amount of states the program can be in. Asynchronous programming is already horrifically bad, it generates combinatorial explosions of intermediary states. Adding imperative, mutative programming and an event-driven API only exacerbates the problem, increasing the amount of possible state sequences even further. I don't think xtream provides a good API.

SodiumFRP provides all the required primitives and is probably super tiny if combined and minified
Is there a measurable performance impact caused by the 100 kb library size?
Yes. Depends on client bandwidth and CPU, but it's significant. In fact, it's insanely high. Not caring about "just another 100 KB" is how people end up with 2-5 MB bundles that take seconds to download on weak networks and seconds to execute on weak devices.
You would have to epxlain how adding a 100kb library make your bundle become 2-5MB huge.

And note that with RxJS 5 you can only pick operators and data structures that you need; I have written a Redux-clone using RxJS [1] which uses Observable, Subject and a handful of operators and is less than 16kb as minified & gzipped in total as self-contained umd bundle (of which half the size is attributed to lodash helper functions).

[1]: https://github.com/Dynalon/reactive-state

This is news to me. Last time I tried RxJS, was unable to get a usable core less than 100 KB minified (I don't use gzip as a metric). Would consider 20-30 KB. Might want to look again.

Bundle size: a typical SPA imports multiple libraries, they import more libraries, and so on. It's not just Rx. I shouldn't have to explain how small things add up. Not caring about size is how it balloons up. We have to care about it in every library.