Navigating the Nuances of toSignal in Angular: What to Know

Netanel Basal
Netanel Basal
Published in
2 min readNov 19, 2023

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:

  1. 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.
  2. 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’s NodeInjector:
@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!

--

--

Netanel Basal
Netanel Basal

Written by Netanel Basal

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

Responses (5)