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

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

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

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

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(...);
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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 githubUsers as a Signal for use in the template without AsyncPipe
  • Track a query Signal that 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: [] }
);
Enter fullscreen mode Exit fullscreen mode

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

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

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$],
);
Enter fullscreen mode Exit fullscreen mode

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

While this other example should yield Signal<number> with value 0.

const combined = computedFrom(
    [first, second$, third$],
    0, // initial value
);
Enter fullscreen mode Exit fullscreen mode

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

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

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 computedFrom function 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.


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 inject function, the Directive Composition API, or NgOptimizedImage, 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 πŸ™Œ