~4 min de lecture
linkedSignal() : le signal writable qui élimine tes anti-patterns effect()
Il y a un pattern que tu as probablement écrit ces derniers mois. Tu as une liste filtrée. Tu as une sélection courante dans cette liste. Quand le filtre change, la sélection doit se réinitialiser. Naturellement, tu t'es tourné vers effect() :
category = signal<string>('all');
selectedProduct = signal<Product | null>(null);
constructor() {
effect(() => {
this.category(); // suivre category
this.selectedProduct.set(null); // reset la sélection
});
}
Ça fonctionne. Mais ce code a trois problèmes.
Premier problème : l'ordre d'exécution de l'effect() n'est pas garanti par rapport aux autres effets du cycle. Deuxième problème : tu utilises effect() pour synchroniser de l'état entre signals — ce n'est pas son rôle (et le guide Angular le dit explicitement : effect() est pour les side effects vers des APIs externes). Troisième problème : depuis Angular 19, le linter signale l'écriture dans un signal depuis un effect() comme une odeur de code.
Il y a une meilleure réponse. Elle s'appelle linkedSignal().
Pourquoi computed() ne suffit pas ici
Avant d'aller plus loin, clarifions pourquoi computed() — l'outil naturel pour dériver de l'état — n'est pas utilisable dans ce cas.
computed() produit un signal en lecture seule. Sa valeur est entièrement déterminée par ses dépendances : elle ne peut pas être modifiée de l'extérieur. C'est parfait pour calculer un total ou filtrer une liste — mais pas pour une sélection que l'utilisateur peut changer.
// ❌ Ne compile pas — computed() retourne ReadonlySignal
selectedProduct = computed(() => null as Product | null);
// Quelque part dans le template ou le composant :
this.selectedProduct.set(product); // Property 'set' does not exist
Le besoin est clair : un signal writable dont la valeur se réinitialise automatiquement quand une dépendance change, mais que l'utilisateur peut toujours modifier entre deux réinitialisations. computed() couvre la moitié, signal() l'autre — linkedSignal() couvre les deux.
linkedSignal() : la forme courte
Depuis Angular 19, @angular/core exporte linkedSignal(). Sa version la plus simple prend une fonction de calcul — exactement comme computed() — et retourne un WritableSignal.
import { Component, signal, linkedSignal } from '@angular/core';
type Product = { id: number; name: string };
@Component({
selector: 'app-catalog',
template: `
<select (change)="category.set($any($event.target).value)">
<option value="all">Tous</option>
<option value="angular">Angular</option>
<option value="rxjs">RxJS</option>
</select>
@for (product of products(); track product.id) {
<div
[class.selected]="selectedProduct()?.id === product.id"
(click)="selectedProduct.set(product)"
>
{{ product.name }}
</div>
}
<p>Sélection : {{ selectedProduct()?.name ?? 'aucune' }}</p>
`,
})
export class CatalogComponent {
protected readonly category = signal('all');
protected readonly products = signal<Product[]>([]);
// Forme courte : reset à null à chaque changement de category
protected readonly selectedProduct = linkedSignal<Product | null>(() => {
this.category(); // déclare la dépendance
return null;
});
}
Le comportement :
- L'utilisateur clique sur un produit →
selectedProduct.set(product)→ la valeur change normalement - L'utilisateur change la catégorie →
categorychange →selectedProductse remet automatiquement ànull - L'utilisateur peut de nouveau sélectionner un produit dans la nouvelle catégorie
C'est exactement le comportement de l'effect() initial, mais sans effect(). La valeur est dérivée de façon déclarative, pas synchronisée de façon impérative.
Note : la forme courte ne donne pas accès à la valeur précédente du signal. Si tu en as besoin, utilise la forme longue.
linkedSignal() : la forme longue
La forme longue décompose en deux propriétés : source (le signal à observer) et computation (la fonction de calcul, avec accès à la valeur précédente).
protected readonly selectedProduct = linkedSignal<Product | null>({
source: this.category,
computation: (category, previous) => {
// category : valeur courante de this.category
// previous : { value: valeur précédente de selectedProduct } | undefined
return null;
},
});
Le paramètre previous est un objet { value: T } (ou undefined lors de la première exécution). Pourquoi un objet plutôt que la valeur directe ? Pour distinguer undefined (pas de valeur précédente) de { value: undefined } (la valeur précédente était undefined).
Use case : garder la sélection si elle est encore valide
La forme longue devient essentielle dès que tu veux une réinitialisation intelligente — pas brutale. Cas typique : une liste filtrée, et tu veux garder la sélection courante si le produit sélectionné existe encore dans la nouvelle liste.
import { Component, signal, linkedSignal, computed } from '@angular/core';
type Product = { id: number; name: string; category: string };
@Component({
selector: 'app-smart-catalog',
template: `
<select (change)="category.set($any($event.target).value)">
<option value="all">Tous</option>
<option value="angular">Angular</option>
</select>
@for (product of filteredProducts(); track product.id) {
<div
[class.selected]="selectedProduct()?.id === product.id"
(click)="selectedProduct.set(product)"
>
{{ product.name }}
</div>
}
`,
})
export class SmartCatalogComponent {
protected readonly allProducts = signal<Product[]>([
{ id: 1, name: 'Formation Angular Signals', category: 'angular' },
{ id: 2, name: 'Maîtriser RxJS', category: 'rxjs' },
{ id: 3, name: 'Angular Architecture', category: 'angular' },
]);
protected readonly category = signal('all');
protected readonly filteredProducts = computed(() => {
const cat = this.category();
return cat === 'all'
? this.allProducts()
: this.allProducts().filter((p) => p.category === cat);
});
protected readonly selectedProduct = linkedSignal<Product | null>({
source: this.filteredProducts,
computation: (products, previous) => {
const prev = previous?.value;
// Garder la sélection si le produit est encore dans la liste filtrée
if (prev && products.find((p) => p.id === prev.id)) {
return prev;
}
// Sinon, sélectionner le premier produit (ou null si la liste est vide)
return products[0] ?? null;
},
});
}
Ce comportement est impossible à reproduire proprement avec effect() — tu devrais lire la liste filtrée et la sélection courante dans le même effect, ce qui crée des dépendances complexes et un timing non garanti. Avec linkedSignal(), la logique est dans la fonction de calcul, et Angular gère le graphe de dépendances.
Use case pagination : page revient à 1 quand le filtre change
Autre pattern ultra-courant : la pagination. Quand l'utilisateur change le filtre ou le tri, la page courante doit revenir à 1. Trois lignes, zéro effect().
import { Component, signal, linkedSignal } from '@angular/core';
@Component({
selector: 'app-paginated-list',
template: `
<input [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value)" />
<p>Page {{ currentPage() }}</p>
<button (click)="currentPage.update(p => p + 1)">Suivante</button>
<button (click)="currentPage.update(p => Math.max(1, p - 1))">Précédente</button>
`,
})
export class PaginatedListComponent {
protected readonly searchQuery = signal('');
// currentPage revient à 1 à chaque changement de searchQuery
protected readonly currentPage = linkedSignal(() => {
this.searchQuery(); // dépendance déclarative
return 1;
});
}
L'utilisateur peut naviguer normalement entre les pages. Dès qu'il modifie la recherche, currentPage revient à 1 automatiquement. Sans listener. Sans ngOnChanges. Sans effect().
Avant / Après complet
❌ Avant — effect() pour synchroniser l'état
import { Component, signal, effect } from '@angular/core';
type Item = { id: number; label: string };
@Component({
selector: 'app-before',
template: `
<button (click)="filter.set('active')">Actifs</button>
<button (click)="filter.set('all')">Tous</button>
@for (item of items(); track item.id) {
<div (click)="selectedItem.set(item)">{{ item.label }}</div>
}
<p>{{ selectedItem()?.label ?? 'aucun' }}</p>
`,
})
export class BeforeComponent {
protected readonly filter = signal('all');
protected readonly items = signal<Item[]>([]);
protected readonly selectedItem = signal<Item | null>(null);
constructor() {
// ⚠️ Anti-pattern : effect() utilisé pour synchroniser de l'état interne
effect(() => {
this.filter();
this.selectedItem.set(null);
});
}
}
✅ Après — linkedSignal()
import { Component, signal, linkedSignal } from '@angular/core';
type Item = { id: number; label: string };
@Component({
selector: 'app-after',
template: `
<button (click)="filter.set('active')">Actifs</button>
<button (click)="filter.set('all')">Tous</button>
@for (item of items(); track item.id) {
<div (click)="selectedItem.set(item)">{{ item.label }}</div>
}
<p>{{ selectedItem()?.label ?? 'aucun' }}</p>
`,
})
export class AfterComponent {
protected readonly filter = signal('all');
protected readonly items = signal<Item[]>([]);
// ✅ linkedSignal : writable + reset automatique sur changement de filter
protected readonly selectedItem = linkedSignal<Item | null>(() => {
this.filter(); // dépendance déclarative
return null;
});
}
Même comportement. Pas d'effect(). Pas d'ordre d'exécution à gérer. La relation entre filter et selectedItem est explicite dans la définition du signal — n'importe quel développeur qui lit le code comprend immédiatement que selectedItem se réinitialise quand filter change.
Récap actionnable
linkedSignal() répond à un besoin précis : un signal writable dont la valeur se recalcule automatiquement quand une dépendance change, mais que l'utilisateur peut aussi modifier entre deux recalculs.
| Situation | Outil |
|---|---|
| Dériver une valeur (lecture seule) | computed() |
| État indépendant modifiable | signal() |
| État dérivé et modifiable | linkedSignal() |
| Side effect vers API externe | effect() |
Avant d'écrire un effect() pour synchroniser deux signals, pose-toi la question : est-ce que la nouvelle valeur se calcule à partir de la source ? Si oui, c'est linkedSignal(), pas effect().
- Forme courte
linkedSignal(() => ...): quand le reset est simple (retourner une valeur par défaut) - Forme longue
linkedSignal({ source, computation }): quand tu as besoin de la valeur précédente pour décider (garder ou réinitialiser intelligemment)
Disponible depuis Angular 19, stable depuis Angular 19.1. Si tu es encore sur Angular 18, les patterns effect() restent l'option disponible — mais note le pattern, la migration linkedSignal() sera la première chose à faire en passant à 19+.