
Managing RxJS Traffic with Signals and Suspensify

Will Signals replace RxJS?
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.
async
push
✨ Let Signals shine !
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.
toSignal()
♻️ Spinner vertigo
@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'
string
toSignal()
undefined
initialValue
recipe = toSignal(this.getRecipe(), {initialValue: '🍕'});
recipe = toSignal(this.getRecipe());
<mc-spinner *ngIf="!recipe()"/>
<mc-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" />
getRecipe()
null
undefined
*ngIf + as
💥 “Oups ! Your valid data is invalid.”
catchError()
@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);
})
)
);
…
}
null
✅ We’re not done yet !
getRecipe()
The current state is neither pending nor done but somewhere in between.
finalize()
catchError()
@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
materialize
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).
suspensify()
This operator produces an observable that will always have an initial value and that never throws an error.
Suspense
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”/>
undefined
toSignal()
suspensify()
recipe = toSignal(this.getRecipe().pipe(suspensify()), {requireSync: true}); // Signal<Suspense<Recipe>>
💪 Type-narrowing in the template
suspensify()
<div *ngIf="suspense.hasError">
{{ suspense.error }} // ✅
{{ suspense.value }} // 💥 template compilation error
</div>
<div *ngIf="suspense.hasValue">
{{ suspense.error }} // 💥 template compilation error
{{ suspense.value }} // ✅
</div>
recipe()
<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.
suspensify
strict
false
error
value
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>
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>
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.