With the introduction of Signals, Angular provides a new reactive building block that makes state management significantly simpler. Its lightweight and accessible API is ideal for developers who want a straightforward way into reactive programming. Many common use cases can now be implemented with minimal code.
RxJS remains the powerful solution for advanced scenarios like streams, cancelation, and complex data flow. The good news: Signals and RxJS integrate seamlessly. This interoperability allows developers to combine simplicity and flexibility as needed.
Signals also work hand in hand with Angular’s OnPush change detection strategy and are designed to support the upcoming zone-less change detection. In fact, they may serve as the foundation for even more fine-grained change detection mechanisms in the future.
This article series explores how to use Angular Signals effectively—from the basics to advanced patterns. This first part covers the foundational building blocks:
signal
, computed
, effect
, and untracked
.
📂 Source Code (see branches signal
and signal-rxjs-interop
)
Using Signals
For using Signals with data binding, properties to be bound are expressed as signals:
@Component([…])
export class FlightSearchComponent {
private flightService = inject(FlightService);
from = signal('Hamburg');
to = signal('Graz');
flights = signal<Flight[]>([]);
[…]
}
It should be noted here that a Signal always has a value. Therefore, a default value must be passed to the signal
function. If the data type cannot be derived from this, the example specifies it explicitly via a type parameter.
The Signal's getter is used to read the value of a signal. Technically, this means that the signal is called like a function:
async search(): Promise<void> {
if (!this.from() || !this.to()) {
return;
}
const flights = await this.flightService.findAsPromise(this.from(), this.to());
this.flights.set(flights);
}
To set the value, the signal offers an explicit setter in the form of a set
method. The example shown uses the setter to stow the loaded flights. The getter is also used for data binding in the template:
<div *ngIf="flights().length > 0">
{{flights().length}} flights found!
</div>
<div class="row">
<div *ngFor="let f of flights()">
<flight-card [item]="f" />
</div>
</div>
Method calls were frowned upon in templates in the past, especially since they could lead to performance bottlenecks. However, this does not generally apply to uncomplex routines such as getters. In addition, the template will appear here as a consumer, and as such, it can be notified of changes.
Meanwhile, Angular's Two-Way-Bindings directly support Signals:
<form #form="ngForm">
<div class="form-group">
<label>From:</label>
<input [(ngModel)]="from" name="from" class="form-control">
</div>
<div class="form-group">
<label>To:</label>
<input [(ngModel)]="to" name="to" class="form-control">
</div>
<div class="form-group">
<button class="btn btn-default" (click)="search()">Search</button>
<button class="btn btn-default" (click)="delay()">Delay</button>
</div>
</form>
In a future version, the Angular team will adopt the forms handling to Signals.
Updating Signals
In addition to the setter shown earlier, Signals also provide an update
method for projecting the current value to a new one:
this.flights.update(f => {
const flight = f[0];
const date = addMinutes(flight.date, 15);
const updated = {...flight, date};
return [
updated,
...f.slice(1)
];
});
Signal Values Should to be Immutable
By default, a Signal's is supposed to be immutable. For this reason, just updating the flight date in the previous section would not be sufficient. Instead, we have to clone the changed parts, so that they get a new object reference.
By comparing these references, Angular's OnPush
change detection strategy can efficiently determine the changed parts of an object managed by a Signal. In the previous section, the Array and the first flight get an new object reference. The other flights are not changed and hence just copied over using slice
. As a result, they keep their object reference.
Calculated Values, Side Effects, and Assertions
Some values are derived from existing values. Angular provides calculated signals for this:
flightRoute = computed(() => this.from() + ' to ' + this.to());
Such a signal is read-only and appears as both a consumer and a producer. As a consumer, it retrieves the values of the signals used - here from
and to
- and is informed about changes. As a producer, it returns a calculated.
If you want to consume signals programmatically, you can use the effect
function:
constructor() {
effect(() => {
console.log('from:', this.from());
console.log('route:', this.flightRoute());
});
}
The effect
function executes the transferred lambda expression and registers itself as a consumer for the signals used. When one of these signals change, the the effect is triggered again.
Please note that usually, Signals are consumed via data binding. However, in some cases, we need a function or method call in order to present a Signal's value to the user. Examples are logging or displaying a toast message. Please find more details on the ideas behind effects here.
Injection Context needed
Several building blocks for Signals such as effect
can only be used in an injection context. This is everywhere inject
is allowed: in the constructor, as default values of class fields, and in provider factories. Also, you can use the runInInjectionContext
function to run code in an injection context.
Hence, the effect set up in the constructor above would fail when placed in ngOnInit
or in another method:
ngOnInit(): void {
// Effects are not allowed here.
// Hence, this will fail:
effect(() => {
console.log('route:', this.flightRoute());
});
}
In this case, you'd get the following error:
ERROR Error: NG0203: effect() can only be used within an injection context such as a constructor, a factory function,
The technical reason is that effects use inject
to get hold of the current DestroyRef
. This service provided since Angular 16 helps to find out about the life span of the current building block, e. g. the current component or service. The effect uses the DestroyRef
to "unsubscribe" itself when this building block is about to be destroyed.
For this reason, you would typically setup your effects in the constructor as shown in the last section. If you really want to setup an effect somewhere else, you can go with the runInInjectionContext
function. However, it needs a reference to an Injector
:
injector = inject(Injector);
ngOnInit(): void {
runInInjectionContext(this.injector, () => {
effect(() => {
console.log('route:', this.flightRoute());
});
});
}
Glitch-Free Property
If a signal changes several times in a row or if several signals change one after the other, undesired interim results could occur. Let's imagine we change the search filter Hamburg - Graz
to London - Paris
:
setTimeout(() => {
this.from.set('London');
this.to.set('Paris');
}, 2000);
Here, London - Graz
could come immediately after the setting from
to London
. Like many other Signal implementations, Angular's implementation prevents such occurrences. The Angular team's readme, which also explains the push/pull algorithm used, calls this desirable assurance "glitch-free".
Signals and Change Detection
Similar to an Observable bound to a template using the async
-Pipe, a bound Signal triggers change detection. This also works for the more efficient OnPush
mode:
@Component({
[...]
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlightSearchComponent { [...] }
[...]
@Component({
[...]
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlightCardComponent { [...] }
However, to help Angular in OnPush
mode to also find out about child components to look at, you have to use Immutables, as discussed above.
RxJS Interop
Admittedly, at first glance, signals are very similar to a mechanism that Angular has been using for a long time, namely RxJS Observables. However, signals are deliberately kept simpler.
If you need the power of RxJS and its operators, you can however convert them to Observables. The namespace @angular/core/rxjs-interop
contains a function toObservable
converting a Signal into an Observable and a function toSignal
for the other way round. They allow using the simplicity of signals with the power of RxJS.
The following listing illustrates the use of these two methods by expanding the example shown into a debounced type-ahead:
@Component([...])
export class FlightSearchComponent {
private flightService = inject(FlightService);
from = signal('Hamburg');
to = signal('Graz');
basket = signal<Record<number, boolean>>({ 1: true });
flightRoute = computed(() => this.from() + ' to ' + this.to());
from$ = toObservable(this.from);
to$ = toObservable(this.to);
flights$ = combineLatest({ from: this.from$, to: this.to$ }).pipe(
filter(c => c.from.length >= 3 && c.to.length >= 3),
debounceTime(300),
switchMap(c => this.flightService.find(c.from, c.to))
);
flights = toSignal(this.flights$, {
initialValue: []
});
}
The example converts the signals from
and to
into the observables from$
and to$
and combines them with combineLatest
. As soon as one of the values changes, filtering and debouncing occur before switchMap
triggers the backend request. One of the benefits flattening operators like switchMap
come with are guarantees in terms of asynchronicity. These guarantees help to avoid race conditions.
The initialValue
passed to toSignal is needed because Signals always have a value. On the contrary, Observables might not emit a value at all. If you are sure your Observable has an initial value, e.g., because it's a BehaviorSubject
or because of using the startsWith
operator, you can also set requireSync
to true
:
flights = toSignal(this.flights, {
requireSync: true
});
If you neither set initialValue
nor requireSync
, the type of the returned Signal also supports the undefinied
type, allowing an initial value of undefined
. In the example shown, this would result in a Signal<Flight[] | undefinied>
instead of Signal<Flight[]>
. Consequently, your code has to check for undefined
too.
Conclusion
Signals make reactivity in Angular more lightweight. They make usual use cases simple and for more complex use cases we can bridge over to RxJS using a first-class interop implementation.
The Angular team remains true to itself: Signals are not hidden in the substructure or behind proxies but made explicit. Developers therefore always know which data structure they are actually dealing with. Also, signals are just an option. No one needs to change legacy code and a combination of traditional change detection and signal-based change detection will be possible.
More on Modern Angular?
Learn all about Standalone Components in our free eBook:
- The mental model behind Standalone Components
- Migration scenarios and compatibility with existing code
- Standalone Components and the router and lazy loading
- Standalone Components and Web Components
- Standalone Components and DI and NGRX
Please find our eBook here:
Feel free to download it here now!