Reputation: 31
There is a very helpful guide Signals vs. Observables at builder.io contrasting the different semantics of signals and observables. Whilst it draws the helpful distinction that signals are "pull" based whereas observables are "push" based it seems there is an important space of semantic, apparently dependent on this design choice, that it does not consider. In the preact-signals test cases at signal.test.tsx there is a significant sequence which verify behaviour in cases where flow graphs rejoin, creating patterns shown in the ASCII art as "flags", "diamonds", etc. The test cases verify an important (to me) data consistency property, that regardless of the shape of the flow graph, upstream observers receive only one notification for each downstream change, and see a "consistent data horizon" where, for example, in the flag pattern, B has been notified definitely before A.
It seems that, if the distinction between "push-based" and "pull-based" reactive value systems is valid, "push-based" systems do not enjoy this property, and "pull-based" systems do not advertise it particularly clearly as a benefit, making it hard to see whether they all do.
Questions -
Here are two worked examples of a "flag-like" pattern in RxJS ("push-based" observables) and preact-signals ("pull-based" signals) where there are two paths of different lengths in the flow graph between the producer and consumer of the values.
In each case we have HTML of a simple button to trigger the changes
<div>
<button id='button'>Button</button>
</div>
Firstly with RxJS:
import { fromEvent, combineLatest } from 'rxjs';
import { scan, map, startWith } from 'rxjs/operators';
const buttonElem = document.getElementById('button');
const clickCount = fromEvent(buttonElem, 'click').pipe(
scan((count) => count + 1, 0),
startWith(0)
);
const derived = clickCount.pipe(map((count) => count + 0.5));
const combiner = map(([clickCount, derived]) => ({ clickCount, derived }));
let triggerCount = 0;
const combinedObs = combineLatest([clickCount, derived]).pipe(combiner);
combinedObs.subscribe(combined => {
++triggerCount;
console.log('Trigger count ', triggerCount, ' combined ', combined);
});
Clicking the button once shows that we get two notifications to the subscriber, where the first one has "janked":
Trigger count 1 combined {clickCount: 0, derived: 0.5}
Trigger count 2 combined {clickCount: 1, derived: 0.5}
Trigger count 3 combined {clickCount: 1, derived: 1.5}
Could this have been avoided with a different choice of RxJS operator? I know there is a choice for "zip" but this assumes that all consumed signals will change synchronously and doesn't allow for combining values some of which have changed at one time and some at another.
Working the same example with preact-signals:
import { signal, computed, effect } from "@preact/signals-core";
const buttonElem = document.getElementById('button');
const clickCount = signal(0);
buttonElem.addEventListener("click", () => ++clickCount.value);
const derived = computed( () => clickCount.value + 0.5);
const combined = computed( () => ({clickCount: clickCount.value, derived: derived.value}));
let triggerCount = 0;
effect( () => {
++triggerCount;
console.log('Trigger count ', triggerCount, ' combined ', combined.value);
});
Produces the following output without jank:
Trigger count 1 combined {clickCount: 0, derived: 0.5}
Trigger count 2 combined {clickCount: 1, derived: 1.5}
Can this beneficial property be taken for granted amongst signals implementations and if not, which ones enjoy it?
Upvotes: 3
Views: 73