Angular Signals & Observables: Differences

oz

Evgeniy OZ

Posted on July 15, 2023

Angular Signals & Observables: Differences

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());
Enter fullscreen mode Exit fullscreen mode

is the same as:

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

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 return 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));
Enter fullscreen mode Exit fullscreen mode

and

const v$ = combineLatest([foo$]).pipe(
  withLatestFrom(bar$),
  map(([[foo], bar]) => foo * bar)
);
Enter fullscreen mode Exit fullscreen mode

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").

One difference that is not applicable: untracked() lets us write to the signals, so we can cause an infinite loop if we use it inside the effect() function.

Signals have no "complete" state

Because of that fact, this code:

const $v = computed(() => $foo() ?? $bar() ?? 0);
Enter fullscreen mode Exit fullscreen mode

is not equal to:

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

or to:

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

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 used 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.


💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.

🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.

💖 💪 🙅 🚩
oz
Evgeniy OZ

Posted on July 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related