Introduction
With the release of Angular 17, the signals have become stable.
As a reminder, a signal is a wrapper around a value that can notify interested consumers when that value changes. Signals can contain any value, from simple primitives to complex data structures.
At present, the core team has only delivered the basic signals api. But its long-term goal is to go much further, and achieve signal change detection.
The next step is to have input and output based on signals. And this functionality should appear very soon this month with version 17.1.
How can we prepare for this functionality to facilitate the migration of our current Input and Output?
A litte reminder
Before getting straight to the heart of the matter, a quick reminder may be in order.
A signal is created using the signal function.
const name = signal<string | null>('DevTo');
This function returns a WritableSignal, enabling us to modify the value of our signal using the methods:
- set
- update
const count = signal(0);
count.set(10);
count.update(count => count + 1);
A signal can also be made readonly using the asReadonly function.
const count = signal(0);
const readonlyCount = count.asReadonly();
Why passing a signal as an input is a very bad idea ?
Working with signals in a real application can sometimes become very complicated, especially with components that take inputs.
As a reminder, modifying change detection in OnPush mode with signals improves performance, especially page refresh speed.
When a signal is used in the template of an OnPush component, Angular automatically adds it as a reactive dependency of the component, and each time this signal is modified, Angular will automatically mark the component as dirty, which will ensure that the component is refreshed the next time the change detection is run.
Now let's imagine two components with a parent-child relationship
@Component({
selector: 'app-father',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [AppChildComponent],
template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
name = signal('DevTo');
}
@Component({
selector: 'app-child',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
template: `{{ name() }}`
})
export class AppFatherComponent {
@Input({ required: true }) name !: WritableSignal<string>
}
Passing a signal as input to a child component have several problems:
- unidirectional binding is lost
- you force the parent to use signals
- you lose the granularity of the detection change
Unidirectional binding and granularity of the detection change
When we pass a signal as input, we don't pass the value of the signal but actually its reference.
It therefore becomes normal that if the signal is modified in the child, the parent is also impacted, so a detection change cycle will have to be lifted in both the parent and the child.
An Angular application is nothing more than a tree of components, so by passing signals as input, no matter whether the component is OnPush or not, each modification of the signals in the child components will automatically trigger a change of detections in the parent, thus losing the added value of the OnPush mode.
What's more, by passing a signal as input, the logic of data centralization is totally lost, as any descendant of a parent component can modify the variable, and in the event of a bug we end up with a spaghetti node that's very complex to debug.
Force the parent to use signals
One of the workarounds used to avoid the above problems would be to switch the signals to readonly mode.
Apart from being unsightly and an anti-pattern, this would force the parent component to use signals. In other words, all inputs passed to child components must be signals.
Which makes no sense, as the parent component should be able to pass simple variables to its children.
What's more, when input signals arrive, all the code will have to be refactored.
Input as a setter for the win
In Angular, inputs can be setters. This will help you to create signal-based inputs.
This technique will avoid the problems analyzed above, but there may well be a few more boilerplates while we wait for release 17.1.
The idea is to transform all our Inputs into an aliased Input setter and create the signal associated with this input.
Let's take the example above
@Component({
selector: 'app-father',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [AppChildComponent],
template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
name = signal('DevTo');
}
@Component({
selector: 'app-child',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
template: `{{ name() }}`
})
export class AppFatherComponent {
@Input({ required: true, alias: 'name' }) set _name(name: string) {
this.name.set(name);
}
name = signal<string>('');
}
With this method, the parent component is totally flexible on the type of variable passed to child components, unidirectional binding is respected and migration to signal inputs is simplified.
In fact, with version 17.1, all you need to do is remove the input setter and replace the signal function with the input function
Inputs Signal
Input signals are declared using the input function
This function not only creates readonly signals, but also ensures that the metadata passed to the input annotation is not lost.
const name = input<string>(''); // input with default value
const name = input<string>(); // input with no default value
const name = input.required<string>() // input mandatory
const name = input<string>('', { alias: 'lastname' }); // with alias
const isLoading = input<string | boolean; boolean>('', { transform: booleanAttribute });
If we go back to our previous example, the code would be as follows:
@Component({
selector: 'app-father',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [AppChildComponent],
template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
name = signal('DevTo');
}
@Component({
selector: 'app-child',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
template: `{{ name() }}`
})
export class AppFatherComponent {
name = input.required<string>();
}
What will happen in the future ?
Based on this shema, reactivity and change of detection within Angular will take place around signals.
In a few months' time, we hope to be able to do without ZoneJs with OnPush components that use signals, as well as creating components based signals like this
@Component({
selector: 'app-father',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
signals: true,
imports: [AppChildComponent],
template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
name = signal('DevTo');
}
Top comments (5)
Input signals look pretty cool but what would the advantage be using them over plain input decorators? The only advantage I can think of is that input signals are readonly.
Other than that, Angular triggers change detection when inputs change anyway so it seems like whether to use plain input decorators, inputs as setters, or input signals doesn't matter anyway.
Yes the change is detected when plain input but how about objects and arrays?
This should remove dependency on ngonchanges previous and current thing
I think some of the code examples have a small typo. Shouldn't the signal in the parent be executed when a string is expected in the child component?
than you :-)
Hi Nicolas Frizzarin,
Your article is very cool
Thanks for sharing