"Observable" is a common pattern for composing a pipeline of sync and async operations. The meaning of this pattern is to give the ability to simplify code decoupling, which is an important and powerful architectural technique. It is already used in various libraries and seems pretty solid.
So, is it worth making it a part of the platform? I don't think so, and here is why...
This article is a summary of my thoughts on these issues: https://github.com/WICG/observable/issues/56 and https://github.com/WICG/observable/issues/41.
When we build a worldwide platform that has no possibilities for breaking changes, we should choose new primitives very carefully. Let's investigate all the pros and cons.
Advantages
It is important to understand that we are talking not about "Observable" as it is, but specifically about adding it to the platform and what benefits this standardization could give us.
- bundlesize improvements, but the proposed core features took only ~1kb, which is not much.
- API standardization for better ecosystem compatibility is an important point, but the interface is simple as it is and there are not many ways to do it wrong.
- event reliability and boilerplate improvements are the most useful and important points of this proposal, but the Observable pattern is not the only way to achieve them.
Drawbacks
What could go wrong if we add Observables to the platform?
- It would introduce another way to accomplish the same thing, which would confuse beginners and make the platform more complex. We already have callbacks, promises, async/await, events, streams, and generators.
- There will always be a shortage of operators. Definitely check out rxjs.dev/api.
- We are uncertain about how it should work. The popular observables library RxJS is constantly improving and changing. Its internal logic and codebase are not simple. So, why do we assume that the current proposed API will be sufficient for us in the future?
The last question is the most important for the web platform.
I have been researching and developing reactive primitives for more than 5 years already, and what I'm sure of is that we still don't know enough about it.
A lot of problems developers encounter are due to the glitch problem. There are many ways to handle it, but there is no universal solution. Here is a brief overview from the "Angular Reactivity with Signals" topic.
Even Angular recently moved a part of the "reactive work" from observables to the new (or old?) "signals". Is this the beginning or the end of the journey?
A good reactive platform should cover a lot of cases: glitches, priority scheduling, contextual execution, aborting, and error handling. And there are no standards in these questions, we are still in the middle of researching and working on it.
So, okay, we will add the current proposal to the platform. Will it still be relevant after 5 or 10 years?
Requirements
The only way we could accept this whole new paradigm on the platform is if it is investigated and integrated well.
We should have strong guidelines (or APIs) for glitch solving. It is heavily related in general with scheduling and the new Prioritized Task Scheduling API, which is currently supported only in Chromium.
I am sure that people will not be satisfied with the current built-in methods and will continue to import libraries with prototype patches (here we go again)! The exact set of methods is a sensitive topic, and I don't think there can be a good design. The only reasonable option is to reuse existing methods from other APIs, which is why iterator-helpers should be investigated and implemented first.
That was my objective opinion as an experienced developer and team lead.
Now, I want to share my personal opinion as a library author.
Workaround
Don't get me wrong, but for me, streams are a very specific and archaic programming model that can only handle a strict pipeline of operations well. When we have conditions in the pipeline, things get a lot more complicated.
One of the most complicated aspects of streams is that you can't put a debugger point on a line of the source code and see all the related variables. In a regular async function, you can easily inspect all the variables in the closures, but stream debugging is much more complicated and not user-friendly.
I have been searching for ways to solve this for many years, and at a certain point, I realized that we don't need additional decorators at all. We can use regular functions and native async
/await
if we accept cancellation context for all our computations. This problem is perfectly solved with the new AsyncContext proposal. The code looks much simpler but remains more flexible for inspections and refactoring.
Let's take a look at the code example from the Observable proposal.
input
.on("input")
.debounce(1000)
.switchMap((promiseOptions /* ??? */) =>
fetch(`/somelookup?q=${input.value}`, { signal: promiseOptions.signal })
)
.switchMap((response) => response.json())
.forEach(updateLookaheadList);
Here is how it could be done differently.
input.oninput = concurrent(async () => {
await sleep(1000); // "lodash" or whatever you want.
const response = await bind(
fetch(`/somelookup?q=${input.value}`, { signal: asyncAbort.get().signal })
);
updateLookaheadList(await bind(response.json()));
});
The utils realization is pretty simple.
let controller = new AbortController();
const asyncAbort = new AsyncContext.Variable(controller);
function concurrent(cb) {
controller.abort();
return asyncAbort.run((controller = new AbortController()), cb);
}
async function bind(promise) {
const result = await promise;
asyncAbort.get().throwIfAborted();
return result;
}
In the code above, the "async" code style is more naive and native, making it much easier to inspect and debug. It's also easier to add conditions to the logic without using additional operators. You can use regular if
, switch
, custom pattern matching, or whatever you want. You only need to care about the async
/Promise
interface, which has been a more general part of the platform for a long time already.
You could add sampling (takeUntil
) easily.
input.oninput = concurrent(async () => {
await promisifyEvent(input, "blur");
const response = await bind(
fetch(`/somelookup?q=${input.value}`, { signal: asyncAbort.get().signal })
);
updateLookaheadList(await bind(response.json()));
});
Utils.
function promisifyEvent(target, type) {
const { promise, resolve, reject } = Promise.withResolvers();
const unsubscribeEvent = onEvent(target, type, resolve);
const unsubscribeAbort = onEvent(asyncAbort.get().signal, "abort", reject);
return promise.finally(() => {
unsubscribeEvent();
unsubscribeAbort();
});
}
function onEvent(target, type, cb) {
target.addEventListener(type, cb);
return () => target.removeEventListener(type, cb);
}
But what about reactivity? It is a challenging topic with numerous edge cases, and I believe it would be preferable to depend on the expertise of library authors to have the ability to choose the needed behavior for your task exactly.
Here is the "streamStock" example from the proposal with my own library: https://www.reatom.dev/package/web/#onevent. I used the explicit ctx
as the first argument in all methods instead of the implicit "AsyncContext". With Reatom, you can have more debugging abilities for your code and manage the streams' lifetimes. I'm not advertising my library; it has limitations that may not be acceptable for some people. I just want to show that everything is possible in many ways. We have different general ideas that we should investigate first. There are no significant reasons to choose Observables until the end of our web trip.
Choosing the right primitive for such a huge platform, as we have, is an important decision. I don't think the Observable will be profitable enough. It could only solve a limited set of tasks, but it would add a huge mental load for newcomers.
Top comments (1)
We need observable primitive. We don't need it as OOP API. Let's take a look at rxjs's lesson.
You don't have and you will never have enough observable operators. So you have to provide a way to define custom ones. Monkey patching of native classes is a problem we have seen already and suggested API doesn't provide any solution. The problem won't go away if you don't look at it. It'll get worse instead.
Operators composition is a crucial part of observable based development. It's not addressed by proposal in any way at all.
So yeah, the proposal of Observable just looks like an attempt to give something without looking into problems other already encountered and tried to solve. And most importantly I don't see why should I switch to it with rxjs still alive.