Converting Observables to Signals in Angular: What You Need to Know
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!