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