Angular Signals & Observables: Differences

🪄 OZ 🎩
4 min readJul 15, 2023

--

“The Kiss”, Gustav Klimt, 1907–1908

Angular 16 brought a new (for Angular) reactivity primitive, and Angular Signals will be, unavoidably, compared to Observables. I’ll highlight their differences.

Signals always have a value

Observables can emit their values synchronously or asynchronously.
Signals don’t emit anything, a consumer should “pull” their value when needed, and some value will always be (synchronously) returned. It’s an important difference and I’ll explain it using RxJS operators and Angular Signals functions.

To variables containing Signals, I’ll add $ as a prefix: $foo.
To variables containing Observables, I’ll add
$ as a suffix: foo$.

combineLatest()

To simplify things, we could say that

const $v = computed(() => $foo() * $bar());

is the same as:

const v$ = combineLatest([foo$, bar$]).pipe(
map(([foo, bar]) => foo * bar)
);

But, this simplification is not quite correct.

combineLatest() and combineLatestWith() will not emit anything, until every given observable will emit at least one value. Signals have no such limitation and you don’t need to worry about it.

We should not forget that every observable passed to combineLatest[With]() should emit a value after we subscribed (or should have a buffered value). Signals have no such limitation.

If one of the observables passed to combineLatest() will complete before emitting a value, the resulting observable will instantly complete and will not emit anything. Signals don’t have a “complete” state, so this limitation also doesn’t apply to signals.

If at least one of the observables errors, combineLatest[With]() will also error and - which is quite important - it will unsubscribe immediately. Signals will just emit an error on every read until a non-error value can be produced. They will not unsubscribe, because they should always have a value.

Also, if in the resulted observable you’ll modify one of the observables, observed by comineLatest() or combineLatestWith(), you’ll create an infinite loop. In computed() and effect() writing to signals is forbidden by default. There are ways to bypass it, but at least you’ll be warned and, most of the time, you’ll avoid an infinite loop.

Another difference is caused by the fact that Signals don’t emit their values, they will notify the consumer, but the consumer should decide when the value should be pulled. In case of computed() it's the moment we read the value of the computed signal, in case of effect() it will happen in the next microtask if at least one notification was received.

Because of that, combineLatest[With]() will emit a value every time when any of the given observables emits a value, but computed() will not emit anything at all, and effect() will emit just one notification, even if multiple signals inside the effect() will emit notifications.

This difference makes signals “glitch-free”, and it can, in fact, remove some pointless execution cycles. But, sometimes such behavior is undesired, and when we need to receive an update from every emitter and run an execution instantly — in such case we need an observable.

withLatestFrom()

Almost all the differences, listed for combineLatest(), are applicable to withLatestFrom().

So, this comparison is also not quite correct:

const $v = computed(() => $foo() * untracked($bar));

and

const v$ = foo$.pipe(
withLatestFrom(bar$),
map(([foo, bar]) => foo * bar)
);

computed() will not be subscribed to $bar, but the resulting signal will not wait for a value from $bar, because signals always have a value, and an error from $bar will not cause termination of the resulting signal (but it will keep returning an error until $bar is “fixed”).

Signals have no “complete” state

Because of that fact, this code:

const $v = computed(() => $foo() ?? $bar() ?? 0);

is not equal to:

const v$ = merge(foo$, bar$).pipe(
map((val) => val ?? 0)
);

or to:

const v$ = race(foo$, bar$).pipe(
map((val) => val ?? 0)
);

Because when one of the passed observables is complete, merge() will keep emitting values of non-complete observables and will ignore the last values of completed observables.

race() will unsubscribe from all observables except the one that emitted its value first, and will keep emitting only values of that observable.

Every RxJS operator cares if an input observable is complete or has an error — Signals don’t care about such nuances. And it’s not a good or bad thing — it’s just a difference, sometimes we want to care about completeness and errors (as with XHR requests), and sometimes we don’t.

Signals are always synchronous

The fact that Signals are synchronous and should always be able to return a value, doesn’t let us implement some operators using Signals:

  • forkJoin()
  • concat()
  • debounce[Time]()
  • switchMap() (and siblings)
  • distinctUntilChanged()
  • filter()
  • skip()
  • takeUntil()

and many others. Everyone who uses them knows how powerful these operators are.

That’s why I picked that painting (one of my favorites) for this article: Observables and Signals are better to be used together, you can use both of their powers and avoid their trade-offs.

--

--