Comprenez et utilisez les Signals Angular pour gérer l'état de façon réactive et performante : signal(), computed(), effect() et migration depuis les.
Prérequis et version Angular
Les Signals sont disponibles à partir d'Angular 16 (mode preview) et sont stables depuis Angular 17. Aucune installation de librairie tierce n'est nécessaire, tout est inclus dans @angular/core.
ng version.
Pour créer un nouveau projet Angular avec les Signals activés par défaut:
npm install -g @angular/cli
ng new mon-projet-signals
cd mon-projet-signals
ng serve
Les imports nécessaires viennent directement de @angular/core — pas de module à ajouter.
Créer et modifier un signal
Un signal est une valeur réactive qui notifie automatiquement les dépendances quand elle change. On le crée avec la fonction signal().
import { signal } from '@angular/core';
// Créer un signal avec une valeur initiale
const compteur = signal(0);
// Lire la valeur (appel comme une fonction)
console.info(compteur()); // 0
// Remplacer la valeur
compteur.set(5);
console.info(compteur()); // 5
// Modifier à partir de la valeur actuelle
compteur.update(valeur => valeur + 1);
console.info(compteur()); // 6
set() vs update()
set(valeur)— remplace complètement la valeur.update(fn)— calcule la nouvelle valeur à partir de l'ancienne.
Pour les signaux de type objet ou tableau, utilise update() en retournant une nouvelle référence:
const utilisateur = signal({ nom: 'Alice', age: 30 });
// Retourner un nouvel objet (immutabilité)
utilisateur.update(u => ({ ...u, age: 31 }));
Signaux dérivés avec computed()
computed() crée un signal en lecture seule dont la valeur est calculée automatiquement à partir d'autres signals. Il se met à jour uniquement quand ses dépendances changent.
import { signal, computed } from '@angular/core';
const prix = signal(100);
const quantite = signal(3);
// Se recalcule si prix ou quantite changent
const total = computed(() => prix() * quantite());
console.info(total()); // 300
prix.set(150);
console.info(total()); // 450
computed() est en lecture seule. On ne peut pas appeler .set() dessus. C'est une valeur dérivée, pas une source.
On peut chaîner plusieurs computed() — Angular ne recalcule que ce qui a réellement changé:
const prixHT = signal(80);
const tva = signal(0.2);
const prixTTC = computed(() => prixHT() * (1 + tva()));
const label = computed(() => `Prix TTC: ${prixTTC().toFixed(2)} €`);
Effets de bord avec effect()
effect() exécute une fonction chaque fois que les signals qu'elle lit changent. C'est l'équivalent de subscribe() pour les Observables, sans gestion manuelle de la désinscription.
import { Component, signal, effect } from '@angular/core';
@Component({ ... })
export class MonComposant {
readonly theme = signal('light');
constructor() {
// S'exécute immédiatement, puis à chaque changement de theme
effect(() => {
document.body.setAttribute('data-theme', this.theme());
console.info('Thème actif:', this.theme());
});
}
}
Règles d'utilisation de effect()
- Doit être créé dans un contexte d'injection (constructeur ou
inject()). - Ne pas modifier un signal depuis un
effect()— risque de boucle infinie. - Angular détruit automatiquement l'effet quand le composant est détruit.
Signal inputs et model()
Angular 17.1+ introduit les signal inputs : une alternative à @Input() basée sur les signaux. Ils s'appellent comme une fonction dans le template et sont intégrés au graphe réactif.
import { Component, input, output, model } from '@angular/core';
// Signal input : remplace @Input()
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<h3>{{ name() }}</h3>
<p>{{ role() }}</p>
<!-- name() s'utilise comme un signal dans computed() -->
<p>{{ displayName() }}</p>
</div>
`
})
export class UserCardComponent {
// input() obligatoire — TypeError si non fourni
name = input.required<string>();
// input() optionnel avec valeur par défaut
role = input('Membre');
// Signal dérivé d'un input
displayName = computed(() => `${this.name()} (${this.role()})`);
}
// Utilisation
// <app-user-card [name]="user.name" [role]="user.role" />
model() — liaison bidirectionnelle avec signaux
import { Component, model, signal } from '@angular/core';
// Composant enfant avec model() — équivalent [(value)]="..."
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="decrement()">−</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
// model() crée une liaison bidirectionnelle
count = model(0);
increment() { this.count.update(v => v + 1); }
decrement() { this.count.update(v => v - 1); }
}
// Composant parent
@Component({
template: `
<p>Valeur parent : {{ quantity() }}</p>
<!-- [(count)]="quantity" — banana-in-a-box avec signal -->
<app-counter [(count)]="quantity" />
`
})
export class ParentComponent {
quantity = signal(5);
// quantity se met à jour automatiquement quand l'enfant modifie count
}
| API legacy | Équivalent Signal API | Différence clé |
|---|---|---|
@Input() name: string |
name = input<string>() |
Retourne un signal lisible |
@Input({ required: true }) |
name = input.required<string>() |
TypeError à la compilation si manquant |
@Output() clicked = new EventEmitter() |
clicked = output<void>() |
Pas de subscribe() nécessaire |
@Input()/@Output() value |
value = model(0) |
Liaison bidirectionnelle via signal |
Interop RxJS : toSignal() et fromSignal()
Angular fournit un pont entre les Observables RxJS et les Signals. toSignal() convertit un Observable en Signal. toObservable() fait l'inverse.
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject, Component, signal } from '@angular/core';
import { switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input (input)="query.set($event.target.value)" placeholder="Rechercher...">
@if (isLoading()) {
<p>Chargement...</p>
}
@for (article of results(); track article.id) {
<p>{{ article.title }}</p>
}
`
})
export class SearchComponent {
private http = inject(HttpClient);
query = signal(''); // Signal writable
// toObservable() : Signal → Observable (pour RxJS operators)
private query$ = toObservable(this.query);
isLoading = signal(false);
// Chaîner des RxJS operators puis convertir en Signal
results = toSignal(
this.query$.pipe(
// debounce non disponible dans les signaux — RxJS requis
debounceTime(300),
switchMap(q => {
if (!q) return of([]);
this.isLoading.set(true);
return this.http.get<Article[]>(`/api/articles?q=${q}`).pipe(
catchError(() => of([]))
);
}),
tap(() => this.isLoading.set(false))
),
{ initialValue: [] as Article[] } // Valeur initiale obligatoire
);
}
Options de toSignal()
// Option initialValue : évite undefined au démarrage
const data = toSignal(http.get('/api/data'), { initialValue: [] });
// Option requireSync : l'Observable doit émettre synchronement
const syncValue = toSignal(of(42), { requireSync: true });
// data() : number (pas number | undefined)
// Injection context manuel (hors constructeur)
const injector = inject(Injector);
const data = toSignal(obs$, { injector });
async pipe par toSignal() + lecture directe dans le template. Cela élimine les subscribe/unsubscribe manuels et améliore la détection des changements.
Pattern Signal Store
Pour la gestion d'état globale, les signaux permettent un pattern "store" léger, sans NgRx ni BehaviorSubject. Un service standalone avec des signaux privés et des méthodes publiques crée un store type-safe minimal.
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
export interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private http = inject(HttpClient);
// État privé — non modifiable de l'extérieur
private _items = signal<CartItem[]>([]);
private _loading = signal(false);
// API publique en lecture seule
readonly items = this._items.asReadonly();
readonly loading = this._loading.asReadonly();
// Valeurs dérivées
readonly totalItems = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly totalPrice = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly isEmpty = computed(() => this._items().length === 0);
// Actions
addItem(item: CartItem) {
this._items.update(items => {
const existing = items.find(i => i.productId === item.productId);
if (existing) {
return items.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { ...item, quantity: 1 }];
});
}
removeItem(productId: number) {
this._items.update(items => items.filter(i => i.productId !== productId));
}
clearCart() {
this._items.set([]);
}
async checkout() {
this._loading.set(true);
try {
await this.http.post('/api/orders', { items: this._items() }).toPromise();
this.clearCart();
} finally {
this._loading.set(false);
}
}
}
// Utilisation dans un composant
@Component({
template: `
<span>Panier ({{ cart.totalItems() }})</span>
<span>{{ cart.totalPrice() | currency:'EUR' }}</span>
@for (item of cart.items(); track item.productId) {
<div>
{{ item.name }} × {{ item.quantity }}
<button (click)="cart.removeItem(item.productId)">✕</button>
</div>
}
`
})
export class CartComponent {
cart = inject(CartStore);
}
Exemple complet dans un composant
Un composant compteur standalone Angular 17+ avec signal, computed et effect:
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-compteur',
standalone: true,
template: `
<h2>Compteur: {{ compteur() }}</h2>
<p>{{ message() }}</p>
<button (click)="incrementer()">+1</button>
<button (click)="reinitialiser()">Reset</button>
`
})
export class CompteurComponent {
readonly compteur = signal(0);
readonly message = computed(() =>
this.compteur() >= 10
? 'Limite atteinte !'
: `${10 - this.compteur()} clics restants`
);
constructor() {
effect(() => console.info('Valeur:', this.compteur()));
}
incrementer() {
this.compteur.update(v => Math.min(v + 1, 10));
}
reinitialiser() {
this.compteur.set(0);
}
}
{{ compteur() }}. Angular détecte les dépendances et ne re-rend que ce qui est nécessaire.
Bonnes pratiques production
- Déclarer les signals
readonlydans les composants pour éviter les mutations externes. - Préférer
computed()àeffect()pour les valeurs dérivées — meilleure performance. - Ne jamais modifier un signal dans un
effect()sans raison valable. - Pour les flux async (HTTP, WebSocket), utiliser
toSignal()de@angular/core/rxjs-interop. - Signals et RxJS coexistent sans problème — migrer progressivement.
Convertir un Observable en Signal avec toSignal():
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject, Component } from '@angular/core';
@Component({ ... })
export class ArticlesComponent {
private http = inject(HttpClient);
// Observable converti en Signal — pas d'async pipe, pas de subscribe
readonly articles = toSignal(
this.http.get<Article[]>('/api/articles'),
{ initialValue: [] }
);
}