Back to overview

Read the right signals – Angular is changing

Reading time approx. 13 minutes
13.12.2023

Over the past two years, the Angular team has apparently taken a new direction. On 8 October 2021, the first discussion about standalone components was started in the official GitHub repository, which was available as an RFC (Request for Comments). The contribution was extremely comprehensive and detailed. The entire community was invited to actively participate in the development process of this new concept.

The participation was encouraging and the feedback from both the community and the Angular team was consistently positive. The concept of standalone components was integrated into the framework with Angular 14. This marked the beginning of further discussions on topics such as "Signal API", "Signal-based Components" and "Control Flow & Deferred Loading". The community also participated intensively in this area.

The RFCs did not just bring superficial adjustments, but fundamental changes that were nevertheless quickly implemented in the framework by the Angular team. The Signal API was introduced with Angular 16, followed by an improved template syntax in Angular 17. Angular 18 is expected to finalise the Signal API with the signal-based components.

Due to these changes, our software development Xperts have taken a closer look at Angular. In the following, we explain what impact the changes will have on the development of Angular applications in the future and how you can prepare for them today.

Signal API

The typical Reactivity API consists of two main concepts: signals and effects. A signal contains a changeable value, while an effect reacts to changes in this value. For a detailed description of the implementation of this API in Angular, we would like to refer you to the official documentation: angular.dev. The aim of our article is not to explain the existing API, but to prepare advanced Angular developers for the future of the framework.

Briefly summarised, Angular's Signal API is based on three basic elements:

  • signal generates a writable signal (WritableSignal).
  • computed creates a calculated signal (Signal).
  • effect creates an effect (EffectRef).
import {computed, effect, signal} from '@angular/core';

const count = signal(1); // WritableSignal<number>
const countOdd = computed(() => count() % 2 > 0); // Signal<boolean>
effect(() => {
  console.log(${count()} is ${countOdd() ? 'odd' : 'even'});
});

await tick();
// 1 is odd

count.set(4);

await tick();
// 4 is even

For developers who are familiar with other frameworks, the parallels to the corresponding concepts in Angular should be clearly recognisable:

  • Vue: ref, computed, watch/watchEffect
  • React: useState, useEffect
  • Svelte: $state, $derived, $effect
  • @preact/signals: signal, computed, effect (here even with the same designations)

Angular does not reinvent the wheel here, but adopts proven techniques from other frameworks.

RxJS

The introduction of the Signal API creates a clear alternative to RxJS, and the long-term goal of Angular is to completely eliminate RxJS as a mandatory dependency. This has numerous advantages. New users no longer have to deal with the steep learning curve of this extensive library, as they can now perform all implementations using Angular's built-in tools. In addition, potential problems associated with the use of RxJS, such as glitches due to inconsistent data and memory leaks due to forgotten subscriptions, are eliminated.

Although RxJS is still indispensable in current development owing especially to its usage in most libraries developed for Angular, the @angular/core/rxjs-interop package provided by Angular allows the usage of RxJS observables in a Signal environment. This allows the advantages of the new approach to be optimally utilised.

Signal-based components

Signal-based components have been introduced to enable seamless integration of signals in Angular applications. This new functionality is activated by setting the signals parameter to true in the @Component decorator . The special feature here is that signal-based components eliminate the use of member decorators such as @Input, @Output, @ViewChild etc.. Instead, these are replaced by corresponding functions such as input, output, viewChild etc.. All options such as required, alias and transform in the @Input decorator are retained in their equivalents.

Example of a signal-based component:

@Component({
  signals: true,
  selector: 'my-temperature',
  template: `
    <p>C: { { celsius() }}</p>
    <p>F: { { fahrenheit() }}</p>
  `,
})
export class MyTemperatureComponent {
  celsius = input<number>({required: true});

  fahrenheit = computed(() => this.celsius() * 1.8 + 32);
}

It is important to note that Angular processes decorators at compile time and the JavaScript bundle does not actually contain decorators. The new signal-based API follows the same approach. Angular processes input, output, viewChild etc. at compile time to understand the application structure. This means that this API cannot be used throughout the code like traditional functions, but only works within the components.

Change detection

Angular's change detection is either based on zone.js or requires the manual use of the ChangeDetectorRef instance within a component. However, with the introduction of the signal-based components, there is no need to configure the changeDetection setting. The signal-based components allow Angular to automatically detect state changes as soon as the value of a signal used in the template changes. This automation effectively eliminates the unnecessary re-rendering of component trees and leads to a significant improvement in performance, especially for large applications.

It is already possible to use signals within components and their templates. If the change detection strategy is set to OnPush, Angular reacts seamlessly to corresponding changes.

Input

In signal-based components, input parameters are defined as signals using the input function. These signals are write-protected and always represent the current value integrated into the component from outside.

zone-based

@Input()
count: number = 0;

signal-based

count = input<number>(0); // Signal<number>

A workaround can already be used today for the use of signals as input parameters. A getter and a setter are declared for the @Input decorator in order to return the corresponding signal and change its value.

@Input()
set count(v: number) {
  this.#count.set(v);
}
get count(): Signal<number> {
  return this.#count.asReadonly();
}
#count = signal<number>(0);

Output

The EventEmitter class is a subclass of Subject from the RxJS library. The initial implementation of the output function should continue to return the same EventEmitter instance. Therefore, the use of the output parameters remains almost unchanged.

zone-based

@Output()
itemSelected = new EventEmitter<string>();

selectItem(item: string): void {
  this.itemSelected.emit(item);
}

signal-based

itemSelected = output<string>(); // EventEmitter<string>

selectItem(item: string): void {
  this.itemSelected.emit(item);
}

Template

The signals are functions and must therefore be used accordingly in the templates. There is no automatic unpacking by Angular.

@Component({
  /*...*/
  template: `<div>{ { user().name }}</div>`,
})
export class MyExampleComponent {
  user = input<User>({required: true}); // Signal<User>
}

A current problem in dealing with signals in templates is that the same return type cannot be guaranteed by TypeScript's Type Guard for successive function calls. This makes it difficult, for example, to perform a nullability check of the signal value in the template in order to use it as non-null. An elegant solution for this is still pending. We therefore recommend creating a reference to the signal value within an if block and using this reference in the content of the block.

@Component({
  /*...*/
  template: `
    @if (user(); as user) {
      <div>{ { user.name }}</div>
    } @else {
      <div>No user is available.</div>
    }
  `,
})
export class MyExampleComponent {
  user = input<User>(); // Signal<undefined | User>
}

Two-way Binding

The conventional bidirectional binding between parent and child components requires some boilerplate code. Signal-based components, on the other hand, simplify this process considerably by introducing the model function. This function creates a WritableSignal instance that automatically forwards the changes to its value to the outside world.

zone-based

@Input()
checked: boolean = false;

@Output()
checkedChange = new EventEmitter<boolean>();

changeChecked(v: boolean) {
  this.checked = v;
  this.checkedChange.emit(v);
}

toggle(): void {
  this.changeChecked(!this.checked);
}

check(): void {
  this.changeChecked(true);
}

uncheck(): void {
  this.changeChecked(false);
}

signal-based

checked = model<boolean>(false); // WritableSignal<boolean>

toggle(): void {
  this.checked.update((v) => !v);
}

check(): void {
  this.checked.set(true);
}

uncheck(): void {
  this.checked.set(false);
}

It is important to note that the "banana-in-a-box" syntax does not work with signals. As the Angular team does not offer currently an alternative for this, it is necessary to use the more detailed variant for the time being.

zone-based

<my-example [(checked)]="value" />

signal-based

<my-example [checked]="value()" (checkedChange)="value.set($event)" />

Queries

The basic principle of queries is retained in signal-based components. The equivalents to @ViewChild and @ContentChild return their result in a signal. This eliminates the need to define a setter if additional control is required for dynamic queries. The equivalents to @ViewChildren and @ContentChildren also return their result in a signal, whereby a simple array is used instead of the complex QueryList class. This significantly reduces the complexity and eliminates the dependency on additional observables.

zone-based

@ContentChildren(MyExampleItemComponent)
items!: QueryList<MyExampleItemComponent>;

@ViewChild('input')
input?: ElementRef<HTMLInputElement>;

signal-based

// Signal<Array<MyExampleItemComponent>>
items = contentChildren(MyExampleItemComponent);

// Signal<undefined | ElementRef<HTMLInputElement>>
input = viewChild<ElementRef<HTMLInputElement>>('input');

A possible workaround for queries as signals could be implemented in a similar way to the approach for input parameters. A setter for @ViewChild and @ContentChild can be called several times, while a setter for @ViewChildren and @ContentChildren is only called once and further changes are propagated via an observable. It is also possible to dispense with the getter by ensuring that the created signal is only ever used in read-only mode.

items = signal<Array<MyExampleItemComponent>>([]);
@ContentChildren(MyExampleItemComponent)
set _items(v: QueryList<MyExampleItemComponent>) {
  this.items.set(v.toArray());
  v.changes.subscribe(() => {
    this.items.set(v.toArray());
  });
}

input = signal<undefined | ElementRef<HTMLInputElement>>(undefined);
@ViewChild('input')
set _input(v: undefined | ElementRef<HTMLInputElement>) {
  this.input.set(v);
}

Host-binding

The Angular team has not yet made a final decision on how to proceed with the two decorators @HostListener and @HostBinding There is no specification yet that prescribes a signal-based alternative. It is currently recommended to temporarily use the host option of the @Directive and @Component decorators.

Lifecycle Hooks

Signal-based components provide support for eight lifecycle hooks. Due to their original dependency on zone.js, these hooks will become redundant in the signal-based components with the introduction of signals and effects. Signal-based components will continue to support the ngOnInit and ngOnDestroy methods, although their use is optional. Initialisation can effectively take place in the class constructor. The cleanup that must be performed when the instance is destroyed can be placed either in the onDestroy callback of the injected DestroyRef instance or in the onDispose callback within an effect method. With the current version, Angular offers three hooks for additional control over the rendering process within components (v17): afterNextRender, afterRender and afterRenderEffect, which are available as a developer preview.

Signals in practice

To illustrate how much easier signals will make our lives, let's take a look at an example. As a requirement, we need a component that expects a boolean as a single input parameter. If this is "true", then the component starts a timer that generates two random numbers every second and calculates their sum.

First, we implement the example in the conventional way using the RxJS functionalities.

export class MyExampleComponent implements OnDestroy {
  constructor() {
    this.subscription.add(
      this.active$
        .pipe(switchMap((active) => (active ? interval(1000) : EMPTY)))
        .subscribe(() => {
          this.n1$.next(genSomeRandomInteger());
          this.n2$.next(genSomeRandomInteger());
        }),
    );
  }

  @Input()
  set active(v: boolean) {
    this.active$.next(v);
  }
  active$ = new BehaviorSubject<boolean>(false);

  n1$ = new BehaviorSubject<number>(0);

  n2$ = new BehaviorSubject<number>(0);

  sum$ = combineLatest([
    this.n1$.pipe(distinctUntilChanged()),
    this.n2$.pipe(distinctUntilChanged()),
  ]).pipe(
    auditTime(0),
    map(([n1, n2]) => n1 + n2),
    share(),
  );

  subscription = new Subscription();

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

Despite the low complexity of the task, the component has become unnecessarily extensive and confusing. The @Input decorator requires a setter to set the value of the subject. To prevent superfluous calculations of the sum, a distinctUntilChanged operator must be placed before each dependent observable. Simply calculating the sum is not enough; auditTime is also required to avoid "glitches" and share, to ensure that the observable is only initialised once. In the ngOnDestroy hook, all subscriptions must also be tidied up. There are many aspects to consider that are not immediately intuitive.

Now we implement the same component in a signal-based way.

export class MyExampleComponent {
  constructor() {
    effect((onCleanup) => {
      if (this.active()) {
        const timerId = setInterval(() => {
          this.n1.set(genSomeRandomInteger());
          this.n2.set(genSomeRandomInteger());
        }, 1000);
        onCleanup(() => {
          clearInterval(timerId);
        });
      }
    });
  }

  active = input<boolean>(false);

  n1 = signal<number>(0);

  n2 = signal<number>(0);

  sum = computed<number>(() => this.n1() + this.n2());
}

The code has become considerably shorter and clearer.

We can go one step further and define a composable to make the logic within the component even easier to understand. Composables are already well-known and popular among developers who are familiar with other frameworks. They offer an elegant way to encapsulate logic in functions and reuse it in different places in the code. This contributes to the creation of clear, modular and maintainable code bases.

We can define the code for setting up the timer as a separate function. This function should be as generic as possible and therefore expects the logic to be executed within the time interval as a callback.

export function useInterval(
  active: Signal<boolean>,
  ms: number,
  callback: {(): void},
): void {
  effect((onCleanup) => {
    if (active()) {
      const timerId = setInterval(callback, ms);
      onCleanup(() => {
        clearInterval(timerId);
      });
    }
  });
}

We can now use this composable in the constructor of the component to make our code even more elegant.

constructor() {
  useInterval(this.active, 1000, () => {
    this.n1.set(genRandomInteger(1, 6));
    this.n2.set(genRandomInteger(1, 6));
  });
}

The example presented illustrates how the introduction of the new signal-based components accelerates the development of applications and makes it more efficient. In addition, the Signal API, especially through the composable approach, opens up a new path for numerous helpful libraries that will further improve the development of applications.

Conclusion

The introduction of signal-based components represents a significant milestone in the development of Angular and is emerging as the upcoming standard in the framework, comparable to the establishment of standalone components. The transition to this new approach may be a challenging task for many developers, as it requires a change in mindset and familiarisation with innovative concepts. It is essential to emphasise that the specification of signals and related concepts has not yet been finalised. Developers have the opportunity to follow the current progress of signal-based components in the development branch of the Angular repository.

For those who do not want to wait for official releases, various workarounds allow integration of new concepts right now. The switch to signal-based components may seem challenging for existing projects, but it is already worth considering using them when developing new projects. This approach allows developers to benefit from the advantages of the new functionalities at an early stage and to familiarise themselves with the future standards of Angular.

Sources

https://angular.dev/guide/signals
https://github.com/angular/angular/discussions
https://github.com/angular/angular/tree/signals