Maîtrisez l'API Signal Components Angular 19+ : input(), output(), model(), transform, alias et interop RxJS pour des composants réactifs sans décorateurs.
Pourquoi les Signal Components ?
Depuis Angular 17.3, les fonctions input(), output() et model() remplacent progressivement les décorateurs @Input(), @Output() et le pattern @Input() + @Output() valueChange. Angular 19 les stabilise et les intègre pleinement à l'outillage (schématics, Angular Language Service, devtools).
L'idée centrale : faire de la communication inter-composants un citoyen natif du graphe réactif des Signals. Avec l'ancien modèle, un @Input() est une propriété TypeScript ordinaire — invisible pour computed() et effect(). Avec input(), la valeur entrante est un Signal, et tout se met à jour automatiquement sans ngOnChanges.
Tableau de correspondance rapide
| Ancien modèle (décorateurs) | Signal Components (Angular 19+) |
|---|---|
@Input() title: string |
title = input<string>() |
@Input({ required: true }) id!: number |
id = input.required<number>() |
@Output() saved = new EventEmitter<void>() |
saved = output<void>() |
@Input() v + @Output() vChange |
v = model<T>() |
ngOnChanges(changes) |
effect(() => { this.input(); }) |
| Propriété TypeScript classique | InputSignal<T> / ModelSignal<T> |
input() — signal d'entrée en profondeur
La fonction input(), importée de @angular/core, retourne un InputSignal<T> — un Signal en lecture seule dont la valeur est définie exclusivement par le composant parent. L'enfant ne peut jamais écrire dans ce Signal.
Déclaration et lecture dans le template
// task-badge.component.ts
import { Component, input } from '@angular/core';
export type TaskStatus = 'todo' | 'in-progress' | 'done' | 'blocked';
@Component({
selector: 'app-task-badge',
standalone: true,
template: `
<!-- Le Signal doit être appelé : status() pas status -->
<span class="badge" [class]="badgeClass()">
{{ label() }}
</span>
`,
})
export class TaskBadgeComponent {
// InputSignal<TaskStatus | undefined> — optionnel
status = input<TaskStatus>();
// InputSignal<string> — avec valeur par défaut
label = input<string>('En cours');
// computed() basé directement sur le Signal status
badgeClass = computed(() => ({
'badge bg-secondary': this.status() === 'todo',
'badge bg-primary': this.status() === 'in-progress',
'badge bg-success': this.status() === 'done',
'badge bg-danger': this.status() === 'blocked',
}));
}
Utilisation côté parent
<!-- task-list.component.html -->
<!-- Passer les valeurs via la syntaxe [propriété]="expression" -->
@for (task of tasks(); track task.id) {
<div class="d-flex align-items-center gap-2 mb-2">
<span>{{ task.title }}</span>
<app-task-badge [status]="task.status" [label]="task.statusLabel" />
</div>
}
Typage selon l'optionnalité
// Sans valeur par défaut → InputSignal<number | undefined>
priority = input<number>();
// Avec valeur par défaut → InputSignal<number> (jamais undefined)
priority = input<number>(0);
// required → InputSignal<number> (erreur de compilation si absent)
priority = input.required<number>();
this.status) retourne l'objet InputSignal, pas sa valeur. C'est une erreur courante lors de la migration. Toujours utiliser this.status() dans la classe et {{ status() }} dans le template.
input() avancé : required, alias, transform
input.required() — forcer le passage d'une valeur
Avec input.required(), Angular génère une erreur de compilation si le composant parent omet l'input. Le type inféré est T (jamais T | undefined), ce qui élimine les vérifications de nullité dans toute la classe.
// product-card.component.ts
import { Component, input, computed } from '@angular/core';
export interface Product {
id: number;
name: string;
priceHT: number;
vatRate: number; // ex: 0.20 pour 20%
stock: number;
}
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ product().name }}</h5>
<!-- priceTTC() recalculé seulement si product() change -->
<p class="card-text text-primary fw-bold">{{ priceTTC() }} €</p>
<span
class="badge"
[class.bg-success]="product().stock > 5"
[class.bg-warning]="product().stock > 0 && product().stock <= 5"
[class.bg-danger]="product().stock === 0"
>
{{ product().stock === 0 ? 'Rupture' : product().stock + ' en stock' }}
</span>
</div>
</div>
`,
})
export class ProductCardComponent {
// Obligatoire — TypeScript infère Product (jamais undefined)
product = input.required<Product>();
// computed() utilise product() sans null-check
priceTTC = computed(() => {
const p = this.product();
return (p.priceHT * (1 + p.vatRate)).toFixed(2);
});
}
alias — séparer nom interne et nom public
L'option alias permet que la propriété de classe TypeScript ait un nom différent du binding template parent. Utile pour respecter des conventions de nommage côté API publique sans polluer l'interface interne.
// weather-icon.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-weather-icon',
standalone: true,
template: `
<!-- Utilise le nom interne dans le template de ce composant -->
<i class="wi wi-{{ iconCode() }}" [title]="description()"></i>
`,
})
export class WeatherIconComponent {
// Parent passe [weatherCode] mais la propriété interne s'appelle iconCode
iconCode = input.required<string>({ alias: 'weatherCode' });
description = input<string>('Conditions météo', { alias: 'weatherLabel' });
}
<!-- template parent — utilise les noms publics (alias) -->
<app-weather-icon
[weatherCode]="forecast.icon"
[weatherLabel]="forecast.description"
/>
transform — normaliser les valeurs entrantes
L'option transform remplace ngOnChanges pour les normalisations simples. La fonction est appelée à chaque changement de valeur avant que le Signal ne soit mis à jour.
// tag-cloud.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-tag-cloud',
standalone: true,
template: `
<div class="d-flex flex-wrap gap-2">
@for (tag of tags(); track tag) {
<span class="badge bg-secondary">{{ tag }}</span>
}
</div>
`,
})
export class TagCloudComponent {
// Accepte une chaîne CSV ou un tableau — normalise en tableau propre
tags = input<string[]>([], {
transform: (value: string | string[]) => {
if (typeof value === 'string') {
// "angular, signals, typescript" → ['angular', 'signals', 'typescript']
return value.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
}
return value.map(t => t.trim().toLowerCase()).filter(Boolean);
},
});
}
<!-- Les deux syntaxes fonctionnent grâce au transform -->
<app-tag-cloud [tags]="'angular, signals, typescript'" />
<app-tag-cloud [tags]="['Angular', ' Signals ', 'TypeScript']" />
transform pour normaliser la valeur entrante (cast de type, trim, parsing). Utilisez computed() pour les dérivations internes basées sur le signal (affichage formaté, filtrage, calculs).
- Utiliser
input.required()dès qu'une valeur est indispensable au rendu - Remplacer
ngOnChangespareffect()oucomputed() - Toujours appeler le signal :
this.title()— jamaisthis.title - Préférer
transformà un setter@Input()personnalisé - Typer précisément — éviter
input<any>()
output() — émissions type-safe
La fonction output() retourne un OutputEmitterRef<T>. Contrairement à EventEmitter (qui étend Subject RxJS), un OutputEmitterRef n'est pas un Observable — il expose uniquement une méthode emit().
Composant avec plusieurs outputs
// invoice-line.component.ts
import { Component, input, output, computed } from '@angular/core';
export interface InvoiceLine {
id: string;
description: string;
quantity: number;
unitPrice: number;
}
@Component({
selector: 'app-invoice-line',
standalone: true,
template: `
<tr>
<td>{{ line().description }}</td>
<td class="text-end">{{ line().quantity }}</td>
<td class="text-end">{{ line().unitPrice.toFixed(2) }} €</td>
<td class="text-end fw-bold">{{ subtotal() }} €</td>
<td>
<!-- Émet la ligne courante au parent -->
<button class="btn btn-sm btn-outline-primary me-1" (click)="lineEdited.emit(line())">
Modifier
</button>
<button class="btn btn-sm btn-outline-danger" (click)="lineRemoved.emit(line().id)">
Supprimer
</button>
</td>
</tr>
`,
})
export class InvoiceLineComponent {
line = input.required<InvoiceLine>();
// Émet l'objet ligne complet pour l'édition
lineEdited = output<InvoiceLine>();
// Émet uniquement l'ID pour la suppression
lineRemoved = output<string>();
subtotal = computed(() =>
(this.line().quantity * this.line().unitPrice).toFixed(2)
);
}
Écoute côté parent — syntaxe identique aux décorateurs
<!-- invoice-editor.component.html -->
<!-- La syntaxe (event)="handler($event)" ne change pas -->
<table class="table">
<tbody>
@for (line of invoice().lines; track line.id) {
<app-invoice-line
[line]="line"
(lineEdited)="openEditModal($event)"
(lineRemoved)="removeLine($event)"
/>
}
</tbody>
</table>
Alias sur output()
// Nom interne différent du nom public exposé dans le template parent
lineRemoved = output<string>({ alias: 'deleteLine' });
// Le parent écoute (deleteLine)="..." mais le code interne appelle this.lineRemoved.emit()
output() n'accepte pas de valeur initiale — il n'a pas d'état. Si vous avez besoin d'un flux Observable en plus de l'émission, utilisez outputToObservable() décrit dans la section suivante.
output() et interopérabilité RxJS
Angular 19 fournit deux helpers dans @angular/core/rxjs-interop : outputFromObservable() convertit un Observable en Output, et outputToObservable() fait l'inverse.
outputFromObservable() — piloter un output depuis un Observable
// live-price.component.ts — affiche un cours boursier en temps réel
import { Component, inject } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { PriceStreamService } from '../services/price-stream.service';
@Component({
selector: 'app-live-price',
standalone: true,
template: `<span class="badge bg-dark">Live</span>`,
})
export class LivePriceComponent {
private priceStream = inject(PriceStreamService);
// Chaque valeur émise par le WebSocket déclenche l'événement parent
// Le parent n'a pas besoin de connaître RxJS
priceUpdated = outputFromObservable(this.priceStream.prices$);
}
<!-- dashboard.component.html — écoute comme un output classique -->
<app-live-price (priceUpdated)="refreshChart($event)" />
outputToObservable() — appliquer des opérateurs RxJS sur un output
// search-page.component.ts
import { Component, viewChild, effect } from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SearchBarComponent } from './search-bar.component';
import { SearchService } from '../services/search.service';
@Component({
selector: 'app-search-page',
standalone: true,
imports: [SearchBarComponent],
template: `
<app-search-bar #searchBar />
<ul class="list-group mt-3">
@for (result of results(); track result.id) {
<li class="list-group-item">{{ result.title }}</li>
}
</ul>
`,
})
export class SearchPageComponent {
searchBar = viewChild.required(SearchBarComponent);
results = signal<SearchResult[]>([]);
private svc = inject(SearchService);
constructor() {
effect(() => {
// Convertit l'output en Observable pour debounce + dedup
outputToObservable(this.searchBar().queryChanged)
.pipe(
debounceTime(300), // attend 300ms d'inactivité
distinctUntilChanged(), // ignore les doublons consécutifs
switchMap(q => this.svc.search(q)) // annule la requête précédente
)
.subscribe(res => this.results.set(res));
});
}
}
debounceTime, switchMap, combineLatest. Pour des réponses directes sans transformation, l'écoute (event)="handler($event)" reste la solution la plus simple.
model() — liaison bidirectionnelle avec les Signals
model() retourne un ModelSignal<T> — un Signal writable que l'enfant peut modifier et dont les changements se propagent automatiquement au parent. C'est l'implémentation Signal du pattern banana-in-a-box [(valeur)].
Slider de budget — exemple concret
// budget-slider.component.ts
import { Component, model, computed } from '@angular/core';
@Component({
selector: 'app-budget-slider',
standalone: true,
template: `
<label class="form-label d-flex justify-content-between">
Budget
<strong class="text-primary">{{ formatted() }}</strong>
</label>
<input
type="range"
class="form-range"
[min]="min()"
[max]="max()"
[step]="step()"
[value]="budget()"
(input)="budget.set(+$any($event.target).value)"
/>
`,
})
export class BudgetSliderComponent {
// ModelSignal<number> — readable ET writable
// L'enfant appelle budget.set() — la valeur remonte au parent
budget = model<number>(0);
min = input<number>(0);
max = input<number>(10000);
step = input<number>(100);
formatted = computed(() =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
.format(this.budget())
);
}
Utilisation parent — banana-in-a-box
// campaign-form.component.ts
import { Component, signal } from '@angular/core';
import { BudgetSliderComponent } from './budget-slider.component';
@Component({
selector: 'app-campaign-form',
standalone: true,
imports: [BudgetSliderComponent],
template: `
<h3>Créer une campagne</h3>
<!-- [(budget)] : le parent lit ET l'enfant peut écrire -->
<app-budget-slider
[(budget)]="campaignBudget"
[min]="500"
[max]="50000"
[step]="500"
/>
<p class="mt-2 text-muted">
Budget sélectionné : {{ campaignBudget() | number:'1.0-0' }} €
</p>
<button class="btn btn-primary" (click)="createCampaign()">
Lancer la campagne
</button>
`,
})
export class CampaignFormComponent {
// Signal writable côté parent — synchronisé avec l'enfant via model()
campaignBudget = signal<number>(2000);
createCampaign(): void {
console.log('Budget final :', this.campaignBudget());
}
}
Écouter les changements explicitement
<!-- Angular génère automatiquement un événement <nom>Change -->
<!-- Ici budgetChange est généré par budget = model() -->
<app-budget-slider
[budget]="campaignBudget()"
(budgetChange)="onBudgetChanged($event)"
/>
onBudgetChanged(newBudget: number): void {
this.campaignBudget.set(newBudget);
this.validateBudget(newBudget); // logique métier déclenchée au changement
}
model() génère automatiquement l'événement <nomDuModel>Change. Ainsi budget = model() expose l'événement budgetChange sans aucune ligne de code supplémentaire — compatible avec [(budget)] et [budget] + (budgetChange).
model() avancé et bonnes pratiques
model.required() — model obligatoire
// color-scheme-picker.component.ts
import { Component, model } from '@angular/core';
export type ColorScheme = 'blue' | 'green' | 'purple' | 'orange' | 'red';
@Component({
selector: 'app-color-scheme-picker',
standalone: true,
template: `
<div class="d-flex gap-2 flex-wrap">
@for (scheme of schemes; track scheme) {
<button
class="btn btn-sm"
[class.active]="scheme === selectedScheme()"
[style.background-color]="schemeColors[scheme]"
(click)="selectedScheme.set(scheme)"
>
{{ scheme }}
</button>
}
</div>
`,
})
export class ColorSchemePickerComponent {
// required : le parent DOIT initialiser la couleur
selectedScheme = model.required<ColorScheme>();
schemes: ColorScheme[] = ['blue', 'green', 'purple', 'orange', 'red'];
schemeColors: Record<ColorScheme, string> = {
blue: '#3b82f6', green: '#22c55e', purple: '#a855f7',
orange: '#f97316', red: '#ef4444',
};
}
Alias sur model()
// language-selector.component.ts
import { Component, model } from '@angular/core';
@Component({
selector: 'app-language-selector',
standalone: true,
template: `
<select class="form-select" [value]="currentLang()" (change)="currentLang.set($any($event.target).value)">
<option value="fr">Français</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
`,
})
export class LanguageSelectorComponent {
// Propriété interne : currentLang — nom public : lang
currentLang = model<string>('fr', { alias: 'lang' });
}
<!-- Le parent utilise l'alias [(lang)] -->
<app-language-selector [(lang)]="userPreferredLang" />
Quand ne PAS utiliser model()
La liaison bidirectionnelle masque le flux de données. Dans des hiérarchies profondes, il devient difficile de tracer qui modifie quoi.
- ✅ Composants UI purs et autonomes (slider, toggle, date-picker, color-picker)
- ✅ Quand l'enfant gère un état local visible par le parent
- ✅ Remplacement direct du pattern
@Input() + @Output() valueChange - ❌ Partage de state applicatif global — utiliser un Signal Store (NgRx)
- ❌ Chaînes
model()imbriquées sur 3+ niveaux de composants - ❌ Muter l'objet directement — toujours utiliser
.set()ou.update()
Réactivité : computed() et effect() avec les inputs
Le véritable gain des Signal Components apparaît lorsqu'on combine input() avec computed() et effect(). Le graphe réactif Angular met à jour automatiquement les valeurs dérivées sans lifecycle manuel.
computed() sur plusieurs inputs
// shipment-cost.component.ts
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-shipment-cost',
standalone: true,
template: `
<div class="alert" [class]="alertClass()">
<strong>Livraison :</strong> {{ shipmentLabel() }}
</div>
`,
})
export class ShipmentCostComponent {
totalWeight = input.required<number>(); // en kg
destination = input.required<string>(); // 'fr' | 'eu' | 'world'
isPremium = input<boolean>(false);
// Tarif calculé à partir de 3 inputs — recalculé seulement si l'un d'eux change
cost = computed(() => {
const base = this.totalWeight() * 1.5;
const mult = this.destination() === 'fr' ? 1 : this.destination() === 'eu' ? 2 : 4;
return this.isPremium() ? 0 : base * mult;
});
shipmentLabel = computed(() =>
this.cost() === 0
? 'Gratuite (premium)'
: `${this.cost().toFixed(2)} €`
);
alertClass = computed(() =>
this.cost() === 0 ? 'alert alert-success' : 'alert alert-info'
);
}
effect() — remplacer ngOnChanges pour les side-effects
// chart-renderer.component.ts
import { Component, input, effect, ElementRef, viewChild, inject } from '@angular/core';
import { ChartService } from '../services/chart.service';
export interface ChartData {
labels: string[];
values: number[];
type: 'bar' | 'line' | 'pie';
}
@Component({
selector: 'app-chart-renderer',
standalone: true,
template: `<canvas #canvas class="w-100"></canvas>`,
})
export class ChartRendererComponent {
chartData = input.required<ChartData>();
height = input<number>(300);
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private charts = inject(ChartService);
constructor() {
// effect() re-run automatiquement si chartData() ou height() change
// Remplace ngOnChanges + ngAfterViewInit combinés
effect(() => {
const data = this.chartData(); // lu : effect re-run si chartData change
const h = this.height(); // lu : effect re-run si height change
this.canvas().nativeElement.height = h;
this.charts.render(this.canvas().nativeElement, data);
});
}
}
linkedSignal() avec un input — Angular 19
// paginated-list.component.ts
import { Component, input, linkedSignal, computed } from '@angular/core';
@Component({
selector: 'app-paginated-list',
standalone: true,
template: `
<ul class="list-group">
@for (item of pageItems(); track item.id) {
<li class="list-group-item">{{ item.label }}</li>
}
</ul>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-sm btn-outline-secondary"
[disabled]="page() === 0"
(click)="page.update(p => p - 1)">← Préc.</button>
<button class="btn btn-sm btn-outline-secondary"
[disabled]="(page() + 1) * pageSize() >= items().length"
(click)="page.update(p => p + 1)">Suiv. →</button>
</div>
`,
})
export class PaginatedListComponent {
items = input.required<{ id: number; label: string }[]>();
pageSize = input<number>(10);
// linkedSignal : remet la page à 0 quand la liste source change
page = linkedSignal({
source: this.items, // se réinitialise si items change
computation: () => 0, // retour à la première page
});
pageItems = computed(() => {
const start = this.page() * this.pageSize();
return this.items().slice(start, start + this.pageSize());
});
}
Migration depuis @Input / @Output
Les décorateurs @Input() et @Output() ne seront jamais supprimés. La stratégie recommandée est une migration composant par composant, en commençant par les composants feuilles (sans enfants).
Avant / Après — composant de notification
// ❌ AVANT — style décorateurs (Angular < 17.3)
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
@Component({ selector: 'app-notification-item', standalone: true, template: '...' })
export class NotificationItemComponent implements OnChanges {
@Input({ required: true }) message!: string;
@Input() type: 'info' | 'warning' | 'error' = 'info';
@Input() autoDismiss = false;
@Output() dismissed = new EventEmitter<void>();
iconClass = '';
ngOnChanges(changes: SimpleChanges): void {
if (changes['type']) {
// Recalcul manuel déclenché par ngOnChanges
this.iconClass = this.computeIcon(this.type);
}
}
private computeIcon(t: string): string { return 'icon-' + t; }
}
// ✅ APRÈS — Signal Components (Angular 19+)
import { Component, input, output, computed } from '@angular/core';
@Component({ selector: 'app-notification-item', standalone: true, template: '...' })
export class NotificationItemComponent {
// Inputs en Signals — plus de ngOnChanges
message = input.required<string>();
type = input<'info' | 'warning' | 'error'>('info');
autoDismiss = input<boolean>(false);
// Output sans EventEmitter
dismissed = output<void>();
// computed() remplace le recalcul manuel dans ngOnChanges
iconClass = computed(() => 'icon-' + this.type());
dismiss(): void {
this.dismissed.emit();
}
}
Migration automatique via schématics
# Migrer les @Input() → input() sur tout le projet
ng generate @angular/core:signal-input-migration
# Migrer les @Output() → output() sur tout le projet
ng generate @angular/core:output-migration
# Cibler un fichier ou dossier spécifique
ng generate @angular/core:signal-input-migration --path=src/app/shared/components
[prop]) et les tests unitaires (fixture.componentInstance.prop). Toujours vérifier les changements générés et lancer les tests avant de commiter.
Cas particulier — classes de base et héritage
// Les inputs Signal ne se transfèrent pas par héritage de classe
// Redéclarer les inputs dans chaque classe enfant si nécessaire
// ❌ Ceci ne fonctionne pas comme attendu
abstract class BaseComponent {
id = input.required<number>(); // Ne sera pas reconnu dans les enfants
}
// ✅ Utiliser une interface ou un mixin à la place
interface WithId {
id: InputSignal<number>;
}
Patterns et architecture
Container / Presentational avec Signal Components
// ---- CONTAINER (smart) : logique métier et état ----
// order-list-container.component.ts
import { Component, inject } from '@angular/core';
import { OrderService } from '../services/order.service';
import { Order } from '../models/order.model';
import { OrderRowComponent } from './order-row.component';
@Component({
selector: 'app-order-list-container',
standalone: true,
imports: [OrderRowComponent],
template: `
@if (orders().length === 0) {
<p class="text-muted">Aucune commande.</p>
} @else {
@for (order of orders(); track order.id) {
<app-order-row
[order]="order"
(orderCancelled)="cancelOrder($event)"
(orderDuplicated)="duplicateOrder($event)"
/>
}
}
`,
})
export class OrderListContainerComponent {
private orderService = inject(OrderService);
orders = this.orderService.orders; // Signal depuis le service
cancelOrder(orderId: string): void { this.orderService.cancel(orderId); }
duplicateOrder(order: Order): void { this.orderService.duplicate(order); }
}
// ---- PRESENTATIONAL (dumb) : affichage pur, sans inject ----
// order-row.component.ts
import { Component, input, output, computed } from '@angular/core';
import { Order } from '../models/order.model';
import { CurrencyPipe, DatePipe } from '@angular/common';
@Component({
selector: 'app-order-row',
standalone: true,
imports: [CurrencyPipe, DatePipe],
template: `
<div class="d-flex justify-content-between align-items-center border-bottom py-2">
<div>
<strong>{{ order().ref }}</strong>
<small class="text-muted ms-2">{{ order().createdAt | date:'dd/MM/yyyy' }}</small>
</div>
<div class="d-flex align-items-center gap-3">
<span class="fw-bold">{{ total() | currency:'EUR':'symbol':'1.2-2':'fr' }}</span>
<span class="badge" [class]="statusBadge()">{{ order().status }}</span>
<button
class="btn btn-sm btn-outline-secondary"
(click)="orderDuplicated.emit(order())"
>Dupliquer</button>
<button
class="btn btn-sm btn-outline-danger"
[disabled]="order().status === 'shipped'"
(click)="orderCancelled.emit(order().id)"
>Annuler</button>
</div>
</div>
`,
})
export class OrderRowComponent {
order = input.required<Order>();
orderCancelled = output<string>();
orderDuplicated = output<Order>();
total = computed(() =>
this.order().lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0)
);
statusBadge = computed(() => ({
'badge bg-warning text-dark': this.order().status === 'pending',
'badge bg-info text-dark': this.order().status === 'processing',
'badge bg-success': this.order().status === 'shipped',
'badge bg-danger': this.order().status === 'cancelled',
}));
}
Composant compatible ControlValueAccessor
// numeric-stepper.component.ts — widget formulaire réutilisable
import { Component, model, input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-numeric-stepper',
standalone: true,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NumericStepperComponent),
multi: true,
}],
template: `
<div class="input-group">
<button class="btn btn-outline-secondary" type="button"
[disabled]="value() <= min()"
(click)="decrement()">−</button>
<input type="number" class="form-control text-center"
[value]="value()" [min]="min()" [max]="max()"
(change)="setValue(+$any($event.target).value)" />
<button class="btn btn-outline-secondary" type="button"
[disabled]="value() >= max()"
(click)="increment()">+</button>
</div>
`,
})
export class NumericStepperComponent implements ControlValueAccessor {
// model() gère l'état local ; CVA notifie Angular Forms
value = model<number>(0);
min = input<number>(0);
max = input<number>(100);
step = input<number>(1);
increment(): void { this.setValue(Math.min(this.value() + this.step(), this.max())); }
decrement(): void { this.setValue(Math.max(this.value() - this.step(), this.min())); }
setValue(v: number): void {
this.value.set(v);
this.onChange(v); // notifie le FormControl Angular
this.onTouched();
}
onChange = (_: number) => {};
onTouched = () => {};
writeValue(v: number): void { this.value.set(v ?? 0); }
registerOnChange(fn: any): void { this.onChange = fn; }
registerOnTouched(fn: any): void { this.onTouched = fn; }
}
model() gère l'état interne du widget (pour la liaison [(value)] standalone), CVA assure la compatibilité avec FormControl et ngModel dans un formulaire Angular.
Conclusion
Les Signal Components d'Angular 19 — input(), output() et model() — unifient la communication inter-composants avec le graphe réactif des Signals. Le résultat en production : suppression des ngOnChanges verbeux, typage plus strict avec input.required(), normalisations déclaratives via transform, et intégration directe avec computed(), effect() et linkedSignal().
La migration est entièrement incrémentale — les anciens décorateurs ne seront jamais retirés — et peut être automatisée via les schématics signal-input-migration et output-migration fournis par Angular CLI. Commencez par les composants feuilles (présentationnels, sans dépendances enfants) pour maximiser la lisibilité de vos reviews.
- Utiliser
input.required()— meilleur typage, zéro null-check inutile - Remplacer
ngOnChangesparcomputed()(dérivations) oueffect()(side-effects) - Utiliser
transformpour normaliser les valeurs entrantes (trim, cast, parse CSV) - Réserver
model()aux composants UI purs et autonomes - Combiner
output()+outputToObservable()pour les pipelines RxJS (debounce, switchMap) - Utiliser
linkedSignal()pour les états réinitialisables liés à un input - Lancer les schématics Angular pour automatiser la migration de la base existante