In collaboration with Chau Tran.
The shift toward signals is picking up serious momentum, and state management libraries are now racing to accommodate both observable-based and signal-based consumers.
Chau and I decided to take a crack at bridging these two worlds into a unified approach.
A bit of context
Observables have been the backbone of Angular since day one. Developers have woven rxjs into nearly every corner of their applications, and it's become second nature.
Angular's reactivity has always relied on rxjs (zone.js aside, which handled change detection automatically).
Then along came signals π¦, and the landscape shifted completely. Templates, change detection, reactivity β the entire mental model got reworked.
Who'd have imagined Angular would actually endorse invoking functions directly inside templates?
@Component({
template: `<div>Count: {{ count() }}</div>`
})
export class MyCmp {
count = signal(0);
}
(That made me laugh out loud β details here: Itβs ok to use function calls in Angular templates!)
Signals are undeniably powerful. Yet old habits die hard β our services are steeped in rxjs subjects, observables, operators, and the whole ritual. Morning or night, that's simply what we reach for.
Example scenario
Imagine a GalleryComponent that pulls data from an API, driven by an id from route parameters and by global form filters.
@Component({
template: `
<div *ngIf="data$ | async as data">
{{ data | json }}
</div>
`
})
export class GalleryComponent {
private route = inject(ActivatedRoute);
private galleryService = inject(GalleryService);
private filterService = inject(GlobalFilterService);
galleryId$ = this.route.paramMap.pipe(map(p => p.get('id')!));
data$ = combineLatest([
this.filterService.filters$,
this.galleryId$
]).pipe(
switchMap(([filters, id]) =>
this.galleryService.getGalleryItems(id, filters)
)
);
favoritesCount$ = this.data$.pipe(map(data => getFavoritesCount(data)));
}
Signals are what I'd rather use in the template for performance gains and forward-compatibility. However, RXJS isn't going anywhere, and I see both as tools that complement each other.
The Angular helper functions approach
The first step is to wrap the data$ observable with toSignal.
data = toSignal(this.data$, { initialValue: [] });
This means the template can now consume the data as a signal, bypassing the async pipe.
Since favoritesCount$ is solely derived from data$, it too can become a signal. A computed signal is the right tool here.
- favoritesCount$ = this.data$.pipe(map(data => getFavoritesCount(data)));
+ favoritesCount = computed(() => getFavoritesCount(this.data()));
All good up to here.
The plan is to migrate the GlobalFilterService from BehaviorSubjects or an existing library like RxAngular / ComponentStore to signals.
Yet, this introduces a problem: the filters signal can't be used directly inside combineLatest. To get that reactive combination working again, the signal has to be turned back into an observable.
Angular's toObservable is the utility for this conversion.
@Component()
export class GalleryComponent {
...
private filterService = inject(GlobalFilterService);
+ filters$ = toObservable(this.filterService.filters);
data$ = combineLatest([
- this.filterService.filters$,
+ this.filters$,
this.galleryId$
]).pipe(...);
}
So far so good. But the component now needs an input for the API call. To expose it reactively, a setter backed by a BehaviorSubject is the usual way.
showStars$ = new BehaviorSubject(false);
@Input({ required: true }) set showStars(x: boolean) {
this.showStars$.next(x);
}
This input works perfectly for combineLatest since it's just an observable. But the template needs this value as well, so the conversion with toSignal happens again.
showStars = toSignal(this.showStars$);
It's a bit more back-and-forth. The Angular RFC hints at a new kind of input on the horizon RFC;
To make a future migration smooth, the setter could write to a signal instead of a BehaviorSubject, which then gets converted to an observable.
The setup above turns into something like this:
showStars = signal(false);
showStars$ = toObservable(this.showStars);
@Input({ required: true }) set showStars(x: boolean) {
this.showStars.set(x);
}
Putting it all together, the full solution looks like this:
@Component()
export class GalleryComponent {
private route = inject(ActivatedRoute);
private galleryService = inject(GalleryService);
private filterService = inject(GlobalFilterService);
galleryId$ = this.route.paramMap.pipe(map(p => p.get('id')!));
filters$ = toObservable(this.filterService.filters);
showStars = signal(false);
showStars$ = toObservable(this.showStars);
@Input({ required: true }) set showStars(x: boolean) {
this.showStars.set(x);
}
data$ = combineLatest([
this.filters$,
this.galleryId$,
this.showStars$
]).pipe(
switchMap(([filters, id, showStars]) =>
this.galleryService.getGalleryItems(id, filters, showStars)
)
);
data = toSignal(this.data$, { initialValue: [] });
favoritesCount = computed(() => getFavoritesCount(this.data()));
}
All of this gymnastics is needed just to feed one value into combineLatest while staying ready for where Angular is heading.
Isn't there a more direct route that respects signals natively?
Chau's initial proposal
Chau was an early adopter in this signals discussion, and he was genuinely excited about it. He had already mapped out several patterns and was kind enough to share a prototype for bridging signals and observables.
His example (a different scenario) shows the following usage:
githubUsers = computed$(
this.query,
pipe(
debounceTime(500),
switchMap((query) => api.getUsers(query)),
startWith([])
)
);
// or with explicit initial value
readonly githubUsers = computed$(
this.query,
[], // initial value
pipe(
debounceTime(500),
switchMap((query) => api.getUsers(query))
)
);
computed$ is a primitive that takes a first argument which can be a signal, observable, or promise. The second argument is either an initialValue or a pipe operator chain for rxjs operators. The operators run whenever the primary source changes.
The complete implementation can be inspected here:
https://gist.github.com/eneajaho/dd74aeecb877069129e269f912e6e472
What gap does computed$ fill? Let's look at the goals:
- Expose
githubUsersas aSignalfor use in the template withoutAsyncPipe - Track a
querySignalthat follows a search input to query the Github API for matching users - Throttle the API calls with a debounce
> Donβt fall into the trap of writing a custom
debounceSignal(). Stick with RxJS for async work.
Two primitives from @angular/core/rxjs-interop, toSignal() and toObservable(), already cover this scenario
githubUsers = toSignal(
toObservable(this.query).pipe(
debounceTime(500),
switchMap((query) => api.getUsers(query))
),
{ initialValue: [] }
);
That gets the job done, although jumping back and forth between toSignal and toObservable can get messy with multiple sources. The appeal of computed$ is its single-source simplicity.
My contribution
Dealing with multiple sources of varying types, both signals and observables, pushed me to adapt Chau's idea. I made the first argument an array that can contain both signals and observables. For a first foray into TypeScript generics, I'd say it went smoothly. The code is available at the provided link.
https://gist.github.com/eneajaho/53c0eca983c1800c4df9a5517bdb07a3
Revisiting the original example with this adapted utility, now called computedFrom, gives us:
@Component()
export class GalleryComponent {
private route = inject(ActivatedRoute);
private galleryService = inject(GalleryService);
private filterService = inject(GlobalFilterService);
galleryId$ = this.route.paramMap.pipe(map(p => p.get('id')!));
showStars = signal(false);
@Input({ required: true }) set showStars(x: boolean) {
this.showStars.set(x);
}
data = computedFrom(
[this.filterService.filters, this.showStars, this.galleryId$],
[], // initial value
pipe(
switchMap(([filters, showStars, id]) =>
this.galleryService.getGalleryItems(id, filters, showStars)
)
)
);
favoritesCount = computed(() => getFavoritesCount(this.data()));
}
No more manual conversions between signals and observables to make combineLatest work.
One caveat with the type safety: without an initial value, the signature becomes Signal<T | undefined>. Providing an initial value tightens it to Signal<T>.
An initial value is particularly useful when consuming the result in a template or as a dependency for other computations, since it avoids null-checks.
Take the favoritesCount case:
favoritesCount = computed(() => {
const data = this.data();
if (data === undefined) return 0;
return getFavoritesCount(data);
});
Chau's refined take
I took my solution back to Chau. He was initially enthusiastic, then went away and came back with improvements, pointing out a flaw with the initialValue parameter.
Without an operator, computedFrom would default to an initial value of type any. That's undesirable; when there are known sources, the initial value should actually be the array of those source values.
This example clarifies what I mean:
// Signal with default value
const first = signal(1);
// Observable that emits first value synchronously
const second$ = of(1);
// Observable that emits first value asynchronously
const third$ = timer(5000);
const combined = computedFrom(
[first, second$, third$],
);
My version would return Signal<any> with a value of undefined. That's clearly faulty.
The expectation is something more sensible:
const combined = computedFrom(
[first, second$, third$],
);
// typeof combined - Signal<[number, number, number]>
While this other example should yield Signal<number> with value 0.
const combined = computedFrom(
[first, second$, third$],
0, // initial value
);
Chau argues that an explicit initialValue parameter is unnecessary because:
- Signals come with initial values already
- Synchronous observables can emit right away
In his view, an explicit initial value is only needed for Observables that emit asynchronously. In the example above, only third$ requires that treatment.
const combined = computedFrom(
[first, second$, third$.pipe(startWith(0))],
pipe(map(([f, s, t]) => f + s + t))
);
// if we have only one operator we can write it directly like:
const combined = computedFrom(
[first, second$, third$.pipe(startWith(0))],
map(([f, s, t]) => f + s + t)
);
On top of that, Chau's computedFrom() also supports a Dictionary argument, mirroring combineLatest(). That's a helpful option to have in many situations.
const combined = computedFrom(
{
first: first,
second: second$,
third: third$.pipe(startWith(0))
},
map(({first, second, third}) => first + second + third)
);
Chau's computedFrom implementation is available here: https://gist.github.com/eneajaho/33a30bcf217c28b89c95517c07b94266
Summary
Chau's refined edition has won me over as well. It's been useful in several of my projects, and it works well.
The
computedFromfunction is now available in the ngxtension package πͺ.
Try it out and share your experiences with us, in the comments or on Twitter! π
For a deeper dive into signals, observables, and related libraries, check out the RxAngular GitHub repository as well.
- RFC: @rx-angular/state/signals - extended signal and new eventEmitter
- RFC: funtional @rx-angular/state - the new rxState function
Closing thoughts and resources
Iβm active on Twitter and on my blog with a steady stream of Angular material β breaking news, signals content, video picks, podcast episodes, framework updates, RFCs, pull requests, and more. π
For those looking to go deeper on contemporary Angular capabilities such as standalone components, signals, functional route guards, interceptors, server-side rendering, hydration, the newer
injectfunction, the Directive Composition API, orNgOptimizedImage, our Modern Angular Workshop from Push-Based.io is a great place to start π
Did this post strike a chord and prove valuable for you? If youβre keen on expanding your Angular know-how, you can find me on @Enea_Jahollari or over at dev.to. π
Iβd also be genuinely grateful if you decided to buy me a coffee βοΈ as a show of support. Many thanks in advance π
