Signals in Angular: The Future of Change Detection

Angular is going to rely on a reactive mechanism called Signals to make change detection more lightweight and powerful.

Sarah Drasner, who, as Director of Engineering at Google, also heads the Angular team, spoke a few days ago on Twitter of an Angular renaissance. That’s pretty much it, because in the last few releases there have actually been some innovations that make Angular extremely attractive. Probably the most important are standalone components and standalone APIs.

Next, the Angular team takes care of renewing the change detection. It should be more lightweight and powerful. To do this, Angular is going rely on a reactive mechanism called Signals, which has already been adopted by a number of other frameworks.

Signals will be available from Angular 16. Similar to standalone components, they initially come as a developer preview so that early adopters can gain initial experience. At the time this article was written, a first beta with Signal support was already available.

In this article I will go into this new mechanism and show how it can be used in an Angular application.

📂 Source Code
(see branches signal and signal-rxjs-interop )

Change Detection Today: Zone.js

Angular currently assumes that any event handler can theoretically change any bound data. For this reason, after the execution of event handlers, the framework checks all bound data in all components for changes by default. In the more powerful OnPush mode, which relies on immutables and observables, Angular is able to drastically limit the number of components to be checked.

Regardless if we go with the default behavior or OnPush, Angular needs to know when event handlers have run. This is challenging because the browser itself, not the framework, triggers the event handlers. This is exactly where the Zone.js used by Angular comes into play. Using monkey patching, it extends JavaScript objects such as window or document and prototypes such as HtmlButtonElement, HtmlInputElement or Promise .

By modifying standard constructs, Zone.js can find out when an event handler has run. It then notifies Angular that it takes care of the change detection:

Change detection with Zone.js

While this approach has worked well in the past, it still comes with a few downsides:

  • Zone.js monkey patching is magic. Browser objects are modified and errors are difficult to diagnose.
  • Zone.js has an overhead of around 100 KB. While negligible for larger applications, this is a deal-breaker when deploying lightweight web components.
  • Zone.js cannot monkey-patch async and await as they are keywords. Therefore, the CLI still converts these statements into promises, even though all supported browsers already support async and await natively.
  • When changes are made, entire components including their predecessors are always checked in the component tree. It is currently not possible to directly identify changed components.

Exactly these disadvantages are now compensated with Signals.

Change Detection Tomorrow: Signals

A signal is a simple reactive construct: it holds a value that consumers can read. Depending on the nature of the signal, the value can also be changed, after which the signal notifies all consumers:

How Signals works in principle

If the consumer is the data binding, it can bring the changed values into the component. Changed Components can thus be updated directly.

In the terminology of the Angular team, the signal occurs as a so-called producer. As described below, there are other constructs that can fill this role.

Using Signals

In the future variant of change detection, all properties to be bound are set up 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 by definition. 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.urgent());

  this.flights.set(flights);
}

To set the value, the signal offers an explicit setter with the 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 in the future and as such it can be notified of changes.

from and to are bound to input fields so that the user can define the search filter. To do this, it uses a property binding for value and an event binding for keydown (not shown here) . This unconventional approach is necessary because in this early phase the form support including ngModel is not yet adapted to Signals.

Updating Signals

In addition to the setter shown earlier, Signals come with two other methods for updating their content. These are particularly useful when the new value is calculated from the old one. The following examples uses mutate, one of these two methods, to delay the first loaded flight by 15 minutes:

this.flights.mutate(f => {
  const flight = f[0];
  flight.date = addMinutes(flight.date, 15);
});

The lambda expression expected by mutate takes the current value and updates it. To return a new value based on the old one instead, you can use update:

this.flights.update(f => {
  const flight = f[0];
  const date = addMinutes(flight.date, 15);
  const updated = {...flight, date};

  return [
    updated,
    ...f.slice(1)
  ];
});

Logically, the examples in the two previous listings lead to the same result: the first flight is delayed. From a technical point of view, however, update allows you to work with immutables that are enforced by some libraries such as NGRX for performance reasons.

For the performance of the signal-based data binding, however, it is irrelevant whether mutate or update is used. In both cases, the signal notifies its consumers.

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:

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 with the signals used. As soon as one of these signals change, the side effect introduced in this way is triggered again.

Most consumers use more than one signal. If these 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".

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 and are sufficient in many cases.

For all other cases, signals can be combined with observables. At the time this article was written, there was a pull request from a core team member that extends Signals with two simple functions providing interoperability with RxJS: The fromSignal function converts a signal into an observable, and fromObservable does the opposite.

The following examples illustrates the use of these two methods by expanding the example shown into a type-ahead:

@Component([…])
export class FlightSearchComponent {
  private flightService = inject(FlightService);

  // Signals
  from = signal('Hamburg');
  to = signal('Graz');
  basket = signal<Record<number, boolean>>({ 1: true });
  urgent = signal(false);

  flightRoute = computed(() => this.from() + ' to ' + this.to());
  loading = signal(false);

  // Observables
  from$ = fromSignal(this.from);
  to$ = fromSignal(this.to);

  flights$ = combineLatest({ from: this.from$, to: this.to$ }).pipe(
    debounceTime(300),
    tap(() => this.loading.set(true)),
    switchMap((combi) => this.flightService.find(combi.from, combi.to)),
    tap(() => this.loading.set(false))
  );

  // Observable as Signal
  flights = fromObservable(this.flights$, []);

}

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, debouncing occurs and flights are then loaded. In the meantime, the example sets the signal loading. The example converts the resulting observable into a signal. Thus, the template shown above does not have to be changed.

NGRX and Other Stores?

So far, we directly created and managed the Signals. However, stores like NGRX will provide some additional convenience.

According to statements by the NGRX team, they are working on Signal support. For the NGRX Store, the following bridging function can be found in a first suggestion by well-known team member Brandon Roberts:

flights = fromStore(selectFlights);

Here, the fromStore function grabs the store via inject and retrieves data with the passed selector selectFlights. Internally, NGRX returns this date as Observables. However, the fromStore function converts it to a signal and returns it.

Conclusion

Signals make Angular lighter and point the way to a future without Zone.js. They enable Angular to directly find out about components that need to be updated.

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.

In general, it should be noted that Signals is still in an early phase and will ship with Angular 16 as a developer preview. This allows early adopters to try out the concept and provide feedback. With this, too, the Angular team proves that the stability of the ecosystem is important to them – an important reason why many large enterprise projects rely on the framework penned by Google.

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:

free ebook

Feel free to download it here now!

Don't Miss Anything!


Subscribe to our newsletter to get all the information about Angular.


* By subscribing to our newsletter, you agree with our privacy policy.

Unsere Angular-Schulungen

Top Schulungen

Angular Architektur Workshop

In diesem weiterführenden Intensiv-Kurs lernen Sie, wie sich große und skalierbare Geschäftsanwendungen mit Angular entwickeln lassen.

Remote und In-House
3 Tage
Remote: 22.05. - 24.05.2023
(1 weiterer Termin)
Auch als Firmen-Workshop verfügbar
Mehr Informationen

weitere Schulungen

Aktuelle Blog-Artikel

Nur einen Schritt entfernt!

Stellen Sie noch heute Ihre Anfrage,
wir beraten Sie gerne!

Jetzt anfragen!