Maîtrisez les nouvelles API Angular 18 : input(), output() et model() pour créer des composants réactifs sans décorateurs, plus simples et plus.
Pourquoi les Signal Inputs/Outputs
Angular 16 a introduit les Signals comme nouveau système de réactivité. Angular 18 va plus loin en remplaçant les décorateurs @Input() et @Output() par des fonctions qui retournent des Signals — un seul mécanisme de réactivité unifié dans tout l'application.
| Aspect | Ancienne API (décorateurs) | Nouvelle API (signaux) |
|---|---|---|
| Lecture de valeur | this.title |
this.title() |
| Inputs requis | @Input({ required: true }) + assertion ! |
input.required<T>() — erreur compile |
| Réactivité | ngOnChanges() |
computed(), effect() directs |
| Emission événements | new EventEmitter() |
output<T>() |
| Two-way binding | @Input + @Output Change |
model() |
| Change Detection | Zone.js + OnPush manuel | Granulaire, automatique |
input(), output() et model() sont des fonctions, pas des décorateurs. Elles s'appellent dans le constructeur de la classe et retournent des objets Signal. Cela les rend composables avec computed() et effect().
input() — Signal Input complet
import { Component, input, computed, effect } from '@angular/core';
interface User {
id: number;
firstName: string;
lastName: string;
email: string;
role: 'admin' | 'user';
}
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="user-card" [class.admin]="isAdmin()">
<h3>{{ fullName() }}</h3>
<p>{{ user().email }}</p>
@if (showBadge()) {
<span class="badge">{{ user().role }}</span>
}
</div>
`
})
export class UserCardComponent {
// Input requis — erreur de compilation si non fourni par le parent
user = input.required<User>();
// Input optionnel avec valeur par défaut
showBadge = input(false);
// Input avec alias — le parent utilise [userId] mais le composant lit userId
userId = input.required<number>({ alias: 'id' });
// Input avec transform — convertit automatiquement string → number
maxItems = input(10, {
transform: (v: string | number) => typeof v === 'string' ? parseInt(v) : v
});
// Computed dérivé d'un input signal — se met à jour automatiquement
fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
isAdmin = computed(() => this.user().role === 'admin');
constructor() {
// Effect déclenché à chaque changement de l'input
effect(() => {
console.log('Utilisateur changé:', this.user().id);
// Pas besoin de ngOnChanges !
});
}
}
Inputs depuis les paramètres de route
// Avec withComponentInputBinding() dans app.config.ts,
// les paramètres de route sont automatiquement liés aux inputs
// app.routes.ts
{ path: 'users/:id', component: UserDetailComponent }
// user-detail.component.ts
@Component({ ... })
export class UserDetailComponent {
// Reçoit automatiquement le paramètre :id de la route
id = input.required<string>();
// Computed pour convertir le string de route en number
userId = computed(() => parseInt(this.id()));
}
output() — Signal Output et outputFromObservable
import { Component, output, outputFromObservable } from '@angular/core';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
@Component({
selector: 'app-search-bar',
standalone: true,
template: `
<input
[value]="value"
(input)="onInput($event)"
(keydown.enter)="searched.emit(value)"
placeholder="Rechercher..."
/>
`
})
export class SearchBarComponent {
// output() simple — remplace @Output() EventEmitter
searched = output<string>();
// output avec alias — le parent écoute (queryChange) mais en interne c'est onSearch
onSearch = output<string>({ alias: 'queryChange' });
value = '';
private searchSubject = new Subject<string>();
// outputFromObservable — convertit un Observable en output signal
// Idéal pour debounce, distinctUntilChanged, etc.
debouncedSearch = outputFromObservable(
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged()
)
);
onInput(event: Event) {
this.value = (event.target as HTMLInputElement).value;
this.searchSubject.next(this.value); // Alimente l'Observable debouncé
}
}
// Utilisation dans le parent :
// <app-search-bar
// (searched)="handleSearch($event)"
// (debouncedSearch)="handleDebouncedSearch($event)"
// />
model() — Two-way binding réactif
model() est la combinaison d'un input + output en un seul signal en lecture/écriture. C'est le mécanisme idéal pour les composants de formulaire personnalisés.
import { Component, model, computed } from '@angular/core';
// === COMPOSANT RATING ÉTOILES ===
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
@for (star of stars; track star) {
<span
class="star"
[class.filled]="star <= rating()"
(click)="rating.set(star)"
(mouseenter)="hovered.set(star)"
(mouseleave)="hovered.set(0)"
>★</span>
}
<span class="label">{{ ratingLabel() }}</span>
`
})
export class StarRatingComponent {
// model() : readable + writable signal
// Côté parent : [(rating)]="productRating"
// Génère automatiquement : [rating] input + (ratingChange) output
rating = model(0);
// Model requis
maxStars = model.required<number>();
hovered = signal(0);
get stars() {
return Array.from({ length: this.maxStars() }, (_, i) => i + 1);
}
ratingLabel = computed(() => {
const labels = ['', 'Mauvais', 'Médiocre', 'Correct', 'Bien', 'Excellent'];
return labels[this.rating()] ?? '';
});
}
// Utilisation dans le parent :
// <app-star-rating [(rating)]="productRating" [maxStars]="5" />
// Équivalent explicite :
// <app-star-rating [rating]="productRating" (ratingChange)="productRating = $event" [maxStars]="5" />
viewChild() et contentChild() signaux
Angular 17 a introduit les variantes Signal de @ViewChild et @ContentChild. Elles retournent des Signals lus dans les templates sans nécessiter AfterViewInit.
import { Component, viewChild, contentChild, viewChildren, ElementRef } from '@angular/core';
@Component({
selector: 'app-form-field',
standalone: true,
template: `
<div>
<ng-content />
<input #inputRef type="text" />
<span class="error">{{ errorMessage() }}</span>
</div>
`
})
export class FormFieldComponent {
// viewChild — Signal vers un élément du template
inputRef = viewChild.required<ElementRef>('inputRef');
// viewChildren — Signal vers une liste d'éléments
// allInputs = viewChildren<ElementRef>('inputRef');
// contentChild — Signal vers le contenu projeté
// labelEl = contentChild(LabelComponent);
errorMessage = signal('');
focus() {
// Accès immédiat sans AfterViewInit !
this.inputRef().nativeElement.focus();
}
validate() {
const value = this.inputRef().nativeElement.value;
if (!value) {
this.errorMessage.set('Ce champ est requis');
}
}
}
Tableau comparatif avant/après
// ===== AVANT Angular 18 (décorateurs) =====
import { Component, Input, Output, EventEmitter, ViewChild, OnChanges, SimpleChanges, ElementRef, AfterViewInit } from '@angular/core';
@Component({ ... })
export class OldComponent implements OnChanges, AfterViewInit {
@Input({ required: true }) user!: User;
@Input() theme: string = 'light';
@Input({ alias: 'userId' }) id!: number;
@Output() userChange = new EventEmitter<User>();
@ViewChild('inputRef') inputRef!: ElementRef;
ngOnChanges(changes: SimpleChanges) {
// Obligé d'utiliser ngOnChanges pour réagir aux changements d'inputs
if (changes['user']) {
console.log('user changed', changes['user'].currentValue);
}
}
ngAfterViewInit() {
// Obligé d'attendre AfterViewInit pour accéder aux éléments du template
this.inputRef.nativeElement.focus();
}
}
// ===== APRÈS Angular 18 (signaux) =====
import { Component, input, output, model, viewChild, computed, effect, ElementRef } from '@angular/core';
@Component({ ... })
export class NewComponent {
user = input.required<User>(); // required, erreur à la compile
theme = input('light'); // optionnel avec défaut
id = input.required<number>({ alias: 'userId' }); // alias
userChange = output<User>(); // remplace EventEmitter
inputRef = viewChild.required<ElementRef>('inputRef'); // accès immédiat
// Réactivité via computed et effect — pas de ngOnChanges
fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
constructor() {
effect(() => console.log('user changed', this.user()));
// Accès au viewChild sans AfterViewInit
// this.inputRef().nativeElement.focus(); ← dans afterNextRender
}
}
Impact performance
Les Signal Inputs permettent à Angular de détecter les changements de façon granulaire, sans Zone.js et sans parcourir l'arbre de composants entier.
ChangeDetectionStrategy.OnPush et des Signal Inputs, Angular ne rend le composant que quand un Signal qu'il lit directement change. Zero overhead Zone.js, zero traversée d'arbre.
// Composant optimisé avec Signal Inputs + OnPush
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush, // Toujours utiliser avec Signals
standalone: true,
template: `
<div class="card">
<h3>{{ product().name }}</h3>
<p>{{ discountedPrice() | currency }}</p>
<button (click)="addToCart.emit(product())">Ajouter</button>
</div>
`
})
export class ProductCardComponent {
product = input.required<Product>();
discount = input(0); // Pourcentage de réduction
addToCart = output<Product>();
// Recalculé SEULEMENT si product() ou discount() change
discountedPrice = computed(() =>
this.product().price * (1 - this.discount() / 100)
);
}
Migration et schematics
Angular fournit des schematics de migration automatique pour convertir progressivement les décorateurs en Signal APIs :
# Migration automatique @Input() → input()
ng generate @angular/core:signal-input-migration
# Migration automatique @Output() → output()
ng generate @angular/core:output-migration
# Migration automatique @ViewChild/@ContentChild → viewChild/contentChild
ng generate @angular/core:signal-queries-migration
# Tout migrer en une fois (Angular 19+)
ng generate @angular/core:signals
@Input() communique parfaitement avec un enfant qui utilise input().
Conclusion
Les Signal Inputs/Outputs d'Angular 18 représentent l'avenir du modèle de composants Angular : moins de boilerplate, réactivité fine, et une API cohérente avec l'écosystème Signals.
input.required<T>()remplace@Input({ required: true }) prop!: Toutput<T>()remplace@Output() prop = new EventEmitter<T>()model<T>()pour le two-way binding sur composants de formulaireviewChild.required<T>()— accès template sansAfterViewInitcomputed()eteffect()remplacentngOnChanges()- Toujours coupler avec
ChangeDetectionStrategy.OnPush