Navigating the Nuances of toSignal in Angular: What to Know
Angular provides the toSignal
function that transforms an observable to a signal. Upon examining various developers’ code, I’ve noticed a trend where the toSignal
function is utilized within root providers. For instance:
import { Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class FooService {
interval = toSignal(interval(1000).pipe(tap(console.log)));
}
We employ the toSignal
function to convert the interval
observable into a signal. Next, we proceed to inject this service into our component:
@Component({
selector: 'app-foo',
standalone: true,
templateUrl: './foo.component.html',
})
export class FooComponent {
fooService = inject(FooService);
}
While this approach seems straightforward, it introduces two significant side effects:
- Instant Subscription: Diverging from the typical lazy subscription pattern of observables,
toSignal
initiates an immediate subscription to any observable it receives. This immediate action leads to instant log outputs, regardless of whether the property is actively in use or not, a behavior that might not always be desirable. - Subscription Leaks in Root Services: More critically,
toSignal
uses the injector’s context for unsubscription by default. When used in a root service, this can lead to memory leaks when the consuming component is destroyed. It’s important to emphasize that this consideration applies to all features that utilize the injection context.
@Component({
selector: 'app-foo',
standalone: true,
template: 'interval()'
})
export class FooComponent {
interval = inject(FooService).interval;
}
For instance, toggling a component with <app-foo />
in a conditional block still keeps the interval subscription active even after the component's removal.
@if (show) {
<app-foo />
}
<button (click)="show = !show">Toggle</button>
To mitigate these issues, consider these strategies:
- Pass the Node Injector: Modify the service to accept an
injector
and pass the component’sNodeInjector
:
@Injectable({ providedIn: 'root', })
export class FooService {
getSource(injector: Injector) {
return toSignal(interval(1000).pipe(tap(console.log)), { injector });
}
}
export class FooComponent {
// Pass the NodeInjector
interval = inject(FooService).getSource(inject(Injector));
}
- Component-Level Providers: Alternatively, providing the service at the component level ensures the subscription’s lifecycle is tied to the component.
- Using toSignal within Components: Finally, and most recommended, consider always using
toSignal
directly within components, thereby aligning the lifecycle with the component and avoiding leaks.
Follow me on Medium or Twitter to read more about Angular and JS!