The starting point was a feature where six observables met in a combineLatest to
fill a table. It worked. It was just that nobody understood it any more, myself
included, after two weeks of not looking at it.
What got better immediately
Derived state now reads like a formula rather than a pipeline. A chain of map,
filter and shareReplay becomes a computed you can read in one breath:
readonly visibleRows = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
if (!term) return this.rows();
return this.rows().filter((row) => row.title.toLowerCase().includes(term));
});No subscription, no teardown, no async pipe in the template. And the part that
matters most: the value is always there. I no longer have to wonder whether
anything had been emitted by the first render.
Signals are good at describing state. RxJS is good at describing events over time. Most of the mistakes happen where you force one to do the other's job.
Where I stayed with RxJS
Everything that involves time. Typing with debounceTime, requests that should
cancel each other, retries with backoff. You can rebuild that with signals, but it
gets longer and harder to read — you end up hand-writing what switchMap says in
one word.
My rule now: the edge of the application speaks RxJS, the core speaks signals.
toSignal is the border between them, and you cross it once, not five times.
What I underestimated
effect() is tempting and almost always the wrong answer. Three times I wrote an
effect that set another signal — handing myself back exactly the opacity I had set
out to remove. If a value follows from other values, it is a computed. An effect
is for the outside world: logging, localStorage, repainting a canvas.
Next time I will start from the computed values and only then look at where a
stream is genuinely still needed.