Marmicode
Blog Post
Younes Jaaidi

Managing RxJS Traffic with Signals and Suspensify

by Younes Jaaidi • 
Apr 28, 2023 • 9 minutes
Managing RxJS Traffic with Signals and Suspensify

Will Signals replace RxJS?

While Angular is not the first framework to implement Signals, it is The framework where developers rely most on RxJS. This raises many interrogations on the future of RxJS in the Angular ecosystem and whether Signals will partially or totally replace RxJS.

What we are facing here is what I like to call the which we are used to in the software world.

Considering that spoons probably appeared before forks, the day forks appeared, some might have seen them as a replacement for spoons. When they tried them on mashed potatoes... until the day they were served soup.

On the other hand, it is not necessarily the best fit for describing the reactive graph between a value that we change and the Angular views using it.

That is where Signals come into play and simplify the propagation of changes to the views.

RxJS has a time dimension which is very interesting but it comes with a cost, the cost of using RxJS operators and Angular pipes to “push” the changes to the view.

✨ Let Signals shine!

While there is clearly some overlap between Signals and RxJS, let’s make each one of them shine where it fits best. Unless you don’t care about the download/upload progress, retry capabilities, and anything time-related, you might not need RxJS, but most of the time we'll want to care about these things... except at the view level.

Let's just bridge the gap and "project" the observable's present state to a Signal using toSignal() function, and let's go out for a beer, right?

But wait!

What happens if there is an error?

Also, how do we know if the observable emitted a value or not yet?

And what about an observable that emits multiple values, how can we know if it did complete or not?

♻️ Spinner vertigo

Suppose that we are fetching data from a remote service. Let’s and .

@Component({ … template: `<mc-recipe [recipe]=”recipe()”/>` }) class MyCmp { recipe = toSignal(this.getRecipe()); getRecipe() { return of('🍔').pipe(delay(1000)); } }

That’s when we might hit a common typing issue:

error TS2322: Type 'string | undefined' is not assignable to type 'string'

The child component is expecting a string as an input but the current default behavior of toSignal() is to not require the Observable to emit a value before the Signal is read. It falls back on the default initial value which is undefined and that can be customized using the initialValue option.

So, we can either force people to eat pizza and empty strings:

recipe = toSignal(this.getRecipe(), {initialValue: '🍕'});

Or stick to the default behavior…

recipe = toSignal(this.getRecipe());

…then handle this in the template and show a spinner until the data is ready.

<mc-spinner *ngIf="!recipe()"/>
<mc-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" />

Now, what if for some chaotic reason, the Observable returned by getRecipe() emits a null or undefined value or an empty string?

This also raises, the problem of using *ngIf + as to narrow the type and undergo the boolean coercion as a side effect.

💥 “Oups! Your valid data is invalid.”

Errors happen and we might want to handle them 😉

In fact, if our Observable errors, the Signal will simply throw the error when read. As Signals will mostly be read in the template, there is no convenient way of handling the error.

The first solution that one might think of is using RxJS’s catchError() and .

@Component({ … template: ` <div *ngIf="error()">Oups! Something went wrong.</div> <mc-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" /> `, }) export class AppComponent { error = signal(null); recipe = toSignal( this.getRecipe().pipe( catchError((error) => { this.error.set(error); return of(null); }) ) ); … }

The problem with this approach is that side effects will often cause inconsistencies. As we manually set the error signal, we have to also manually set it to null when back on track, otherwise, here is what can happen:

The error sticks there even after we move to the next recipe.

✅ We’re not done yet!

Remember that Observables can emit multiple values! Suppose that our getRecipe() method still fetches the recipe from a remote service but it might emit a first value from some cache while it fetches a fresher version of the recipe from a remote service.

Even if we receive the first cached value, we might want to still show a progress bar until the stream is complete in order to let the user know that we are still trying to load some additional or fresher data.

The problem we have here is that the Signal somehow “swallowed” the “complete” notification and we have no built-in way of knowing if the stream is finished or not. We have to figure out a better way than just like we did with catchError(). Otherwise, we would encounter the same inconsistency problems described above.

@Component({ … template: ` <mc-progress-bar *ngIf="!finalized()"/> <app-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" /> `, }) export class AppComponent { finalized = signal(false); recipe = toSignal( this.getRecipe().pipe( finalize(() => this.finalized.set(true)) ) ); … }

🪄 Suspensify

this.getRecipe()
  .pipe(suspensify())
  .subscribe(value => console.log(value));

{pending: true, finalized: false, hasError: false, hasValue: false}
{pending: false, finalized: false, hasError: false, hasValue: true, value: '🍔'}
{pending: false, finalized: true, hasError: true, hasValue: false, error: '💥'}

recipe = toSignal(this.getRecipe().pipe(suspensify())); // Signal<Suspense<Recipe> | undefined>

<!-- error TS2532: Object is possibly 'undefined' -->
<mc-progress-bar *ngIf=”!recipe().finalized”/>

recipe = toSignal(this.getRecipe().pipe(suspensify()), {requireSync: true}); // Signal<Suspense<Recipe>>

<div *ngIf="suspense.hasError">
  {{ suspense.error }} // ✅
  {{ suspense.value }} // 💥 template compilation error
</div>

<div *ngIf="suspense.hasValue">
  {{ suspense.error }} // 💥 template compilation error
  {{ suspense.value }} // ✅
</div>

<div *ngIf="recipe().hasError">
  {{ recipe().error }} // 💥 template compilation error
  {{ recipe().value }} // 💥 template compilation error
</div>

<div *ngIf="recipe().hasValue">
  {{ recipe().error }} // 💥 template compilation error
  {{ recipe().value }} // 💥 template compilation error
</div>

recipe = toSignal(this.getRecipe().pipe(suspensify({strict: false})));
<div *ngIf="recipe().hasError">
  {{ recipe().error }} // ✅
  {{ recipe().value }} // ✅
</div>

<div *ngIf="recipe().hasValue">
  {{ recipe().error }} // ✅
  {{ recipe().value }} // ✅
</div>

<ng-container *ngIf=”recipe() as suspense”>
  <div *ngIf="suspense.hasError">
    {{ suspense.error }} // ✅
    {{ suspense.value }} // 💥
  </div>

  <div *ngIf="suspense.hasValue">
    {{ suspense.error }} // 💥
    {{ suspense.value }} // ✅
  </div>
</ng-container>

@Component({ … template: ` <mc-progress-bar *ngIf=”!recipe().finalized”/> <div *ngIf="recipe().hasError"> {{ recipe().error }} </div> <div *ngIf="recipe().hasValue"> {{ recipe().value }} </div> ` }) class MyCmp { recipe = toSuspenseSignal(this.getRecipe()); getRecipe(): Observable<Recipe> { … } } function toSuspenseSignal<T>(source$: Observable<T>) { return toSignal(source$.pipe(suspensify({ strict: false })), { requireSync: true, }); }