Converting Observables to Signals in Angular: What You Need to Know

Netanel Basal
Netanel Basal
Published in
3 min readApr 30, 2023

--

Angular v16 comes with a new package named rxjs-interop, which introduces the toSignal function that converts an observable to a signal. In this article, we’ll take a closer look at this new feature and its usage.

To start using the toSignal function, we need to import it from the @angular/core/rxjs-interop module. Here’s an example code snippet that demonstrates its usage:

import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000);
counter = toSignal(this.counter$);
}

In this example, we have created an observable using the interval function with a period of 1 second. The toSignal function is then used to convert this observable to a signal. The resulting signal has the type Signal<number | undefined>, which means that it can produce undefined values since there is no initial value for our observable.

It’s worth noting that, unlike the async pipe, we can read the value of the signal immediately in our component, which can produce undefined.

Moreover, the toSignal function subscribes to the observable immediately, which can cause unwanted results in some cases if there are side effects.

If we have a code that uses the async pipe with an ngIf directive, it will subscribe to the observable only when we render the template.

@Component({
standalone: true,
template:`<div *ngIf="someCondition">
<div *ngFor="let item of source$ | async"></div>
</div>`,
})
export class FooComponent {
source$ = inject(Service).someMethod();
}

However, if we change it to use toSignal instead, it won’t have the same behavior since it will subscribe immediately and NOT based on the ngIf condition’s result.

In case we want to remove the undefined type from our resulting signal, we have two options. The first one is to pass an initial value when we have an async observable that doesn’t fire immediately.

@Component({
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000);
counter = toSignal(this.counter$, { initialValue: 0 });
}

The second option, in case the source emits immediately, is to pass the requireSync option.

@Component({
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000).pipe(startWith(0));
counter = toSignal(this.counter$, { requireSync: true });
}

However, if we choose this option and the observable doesn’t emit immediately, Angular will throw an error.

When the toSignal function is called, it first checks to ensure that it is being called in an injection context. If not, an error will be thrown. That means that we can use the toSignal function only when the inject() function is available except for cases when we use the manualCleanup option or pass an injector explicitly.

The reason for this is that Angular will auto-unsubscribe when the wrapping context is destroyed. It does this using the new OnDestroy hook that it obtains from using the inject() function or the explicitly provided injector:

@Component({
selector: 'foo',
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000);
counter: Signal<number | undefined>;
private injector = inject(Injector);

ngOnInit() {
this.counter = toSignal(this.counter$, { injector: this.injector } );
}
}

If the subscription should persist until the observable itself completes, we can specify the manualCleanup option instead.

@Component({
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000).pipe(take(3));
counter = toSignal(this.counter$, { manualCleanup: true });
}

Handling errors with the toSignal function is straightforward. When the observable emits an error, Angular will throw it, and we can handle it with a try-catch block.

@Component({
standalone: true,
template:`{{ counter() }}`,
})
export class FooComponent {
counter$ = interval(1000);
counter = toSignal(this.counter$, { initialValue: 0 });

ngOnInit() {
try {
this.counter();
} catch (e) {
console.log(e);
}
}
}

In summary, the rxjs-interop and toSignal function in Angular v16 is a new feature that allows converting an observable to a signal. Its usage is simple and straightforward, but we need to be careful while using it to avoid unexpected results.

Follow me on Medium or Twitter to read more about Angular and JS!

--

--

A FrontEnd Tech Lead, blogger, and open source maintainer. The founder of ngneat, husband and father.