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));
}
}
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?
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);
})
)
);
…
}
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.
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))
)
);
…
}
🪄 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));
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: '💥'}
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>
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”/>
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>>
💪 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>
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>
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})));
<div *ngIf="recipe().hasError">
{{ recipe().error }} // ✅
{{ recipe().value }} // ✅
</div>
<div *ngIf="recipe().hasValue">
{{ recipe().error }} // ✅
{{ recipe().value }} // ✅
</div>
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>
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,
});
}
🧳 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
Top comments (0)