DEV Community

Cover image for Managing RxJS Traffic with Signals and Suspensify
Younes Jaaidi for This is Angular

Posted on • Originally published at marmicode.io

Managing RxJS Traffic with Signals and Suspensify

Will Signals replace RxJS?

TL;DR: No! Not totally.

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 Spoon-and-Fork situation 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.

Each time two solutions overlap in solving some specific problem, the last one released can be hastily and mistakenly considered as a total replacement of the older one.

RxJS provides unequaled ways of managing time-related manipulations of push-based data streams like buffering, throttling, retrying, exponential backoff, or orchestration through flattening strategies.
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.

If you want to learn more about Signals, I highly recommend this blog post by my friend Tomas Trajan, and also my own blog post 😅 if you want a quick deep dive in some internals

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

Signals on the other hand don’t have any stream-awareness or time-awareness but they are pretty good at building a reactive graph and propagating changes from the value change to the view and through the transformations made by "computed" signals.

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

What matters most at the component/view level is the current state. A component lives in the present, and Signals are a representation of the present.

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 convert the Observable to a Signal and forward it to a child component.

@Component({
  
  template: `<mc-recipe [recipe]=”recipe()”/>`
})
class MyCmp {
  recipe = toSignal(this.getRecipe());

  getRecipe() {
    return of('🍔').pipe(delay(1000));
  }
}
Enter fullscreen mode Exit fullscreen mode

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

error TS2322: Type 'string | undefined' is not assignable to type 'string'
Enter fullscreen mode Exit fullscreen mode

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: '🍕'});
Enter fullscreen mode Exit fullscreen mode

Or stick to the default behavior…

recipe = toSignal(this.getRecipe());
Enter fullscreen mode Exit fullscreen mode

…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" />
Enter fullscreen mode Exit fullscreen mode

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

In that case, the spinner would just keep spinning because we can’t make any difference between the pending status and an emitted value that matches the initial value.

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 (and those computed from it) 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 setting the error in another signal as a side effect.

@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);
      })
    )
  );

  
}
Enter fullscreen mode Exit fullscreen mode

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:

RxJS to Signal Error

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

In order to fix this, we can either add some additional spaghetti code… or wait a bit. Sorry for the suspense!

✅ 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 current state is neither pending nor done but somewhere in between.

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 combining the finalize() operator with another Signal 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))
    )
  );

  
}
Enter fullscreen mode Exit fullscreen mode

🪄 Suspensify

We need to figure out a way of mapping Observables to a richer type of Signal that would contain a consistent and current projection of the Observable's state.

The materialize operator sounds like the natural option, but it doesn’t solve our problem for the following reasons:

  • it doesn’t emit anything before the first value, error, or “complete” notification, so we have to emit in initial “started” notification,
  • the “complete” notification doesn’t contain the last emitted value, so we have to somehow remember it (e.g. by applying a reducer using the scan operator).

This “Observable projection problem” is not new or solely related to Signals. This also happens when we try to project the current status of an asynchronous task in NgRx effects or when using RxAngular’s connect method.
That is the reason why we created the suspensify() operator with my friend Edouard Bozon a few years ago. Cf. https://github.com/jscutlery/devkit/tree/main/packages/operators

This operator produces an observable that will always have an initial value and that never throws an error.

Instead of emitting the Observable values, it will emit a Suspense object containing different properties that will tell us about the current state of the Observable.

The example below:

this.getRecipe()
  .pipe(suspensify())
  .subscribe(value => console.log(value));
Enter fullscreen mode Exit fullscreen mode

could emit the following values if the Observables emits a value then fails.

{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: '💥'}
Enter fullscreen mode Exit fullscreen mode

We can turn this into a Signal that will always have an initial value and never throw.

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

Once we try to use this in the template like this we stumble upon an error

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

In fact, the default initial value of a Signal is undefined but we can fix the typing by letting toSignal() know that our Observable will synchronously emit an initial value as we are using suspensify():

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

💪 Type-narrowing in the template

The default behavior of suspensify() is to return a type union in order to help with type narrowing.

Here is an example:

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

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

While this will avoid some common mistakes and allow us to propagate the right type to the children, it currently doesn’t play well with Signals because the compiler doesn’t know that recipe() will always return the same value during a Change Detection cycle.

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

This is currently in the Angular roadmap and will be fixed in some future version, at least for signal-based components.

Meanwhile, the workaround is to set suspensify’s strict option to false so that the error and value properties are always available but potentially undefined.

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

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

Another alternative is to use create a local variable using ngIf + as:

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

While this workaround provides better type narrowing, it is not very convenient and according to Signals RFC, this will probably not work in signal-based components 😕
… but remember! Signal-based components will probably ship with type narrowing in the template.

Finally, we can quickly wrap this in a reusable function:

@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,
  });
}
Enter fullscreen mode Exit fullscreen mode

🧳 Key Takeaways

  • 🤝 Signals are not meant to replace Observables.
  • 💪 A simple operator like suspensify() can keep things declarative, and thus consistent, avoiding imperative spaghetti.
  • 🐙 You can use suspensify() in NgRx effects or with RxState in order to connect different sources.

🔗 Links & Upcoming Workshops

👨🏻‍🏫 Workshops

📦 Suspensify Operator

💻 Source Code on Stackblitz

📰 Subscribe to Newsletter

💬 Discuss this on github

Top comments (0)