Découvrez linkedSignal et la resource API d'Angular 19 : gérez les dépendances entre Signals et les appels HTTP asynchrones de façon réactive et efficace.
linkedSignal() : signaux dérivés modifiables
Angular 19 introduit linkedSignal(), un nouveau type de signal qui combine les propriétés de signal() (modifiable par .set() et .update()) et de computed() (dérivé automatiquement d'une source). Il répond au cas précis où un état doit être initialisé et réinitialisé depuis une source, tout en pouvant être modifié localement par l'utilisateur.
Différence avec computed()
| computed() | linkedSignal() | signal() | |
|---|---|---|---|
| Dérivé d'une source | Oui | Oui | Non |
| Modifiable (.set) | Non | Oui | Oui |
| Réinitialisation auto | Toujours recalculé | Sur changement de source | Non |
| Cas d'usage | Valeur dérivée pure | Sélection/filtre local | État indépendant |
Exemple : sélection de produit avec reset automatique
import { Component, signal, linkedSignal } from '@angular/core';
interface Product { id: number; name: string; }
@Component({
selector: 'app-product-select',
standalone: true,
template: `
<!-- Changer la catégorie recharge les produits ET réinitialise la sélection -->
<select (change)="category.set($event.target.value)">
<option value="frontend">Front-end</option>
<option value="backend">Back-end</option>
</select>
<select (change)="selectedId.set(+$event.target.value)">
@for (p of products(); track p.id) {
<option [value]="p.id" [selected]="p.id === selectedId()">
{{ p.name }}
</option>
}
</select>
<p>Sélectionné : {{ selectedId() }}</p>
`
})
export class ProductSelectComponent {
category = signal('frontend');
// Recalculé quand category change
products = computed(() =>
this.category() === 'frontend'
? [{ id: 1, name: 'Angular' }, { id: 2, name: 'React' }]
: [{ id: 3, name: 'Node.js' }, { id: 4, name: 'FastAPI' }]
);
// Se réinitialise à products()[0].id quand products() change
// Mais l'utilisateur peut aussi le modifier manuellement
selectedId = linkedSignal(() => this.products()[0].id);
}
category change → products est recalculé → selectedId se réinitialise automatiquement au premier produit de la nouvelle liste. L'utilisateur peut ensuite choisir un autre produit sans que ça ne recharge la liste.
Options avancées et patterns courants
La forme longue de linkedSignal() permet de contrôler précisément comment la réinitialisation est calculée, en accédant à la valeur précédente via le paramètre previous.
Conserver la sélection si elle est toujours valide
// Cas réel: pagination — conserver le numéro de page si les filtres changent
// mais seulement si la page existe encore dans les nouveaux résultats
currentPage = linkedSignal({
source: this.totalPages, // signal du nombre total de pages
computation: (totalPages, previous) => {
const prevPage = previous?.value ?? 1;
// Si la page actuelle est toujours valide, la conserver
return prevPage <= totalPages ? prevPage : 1;
},
});
// Autre cas: conserver la sélection si l'item existe encore dans la liste
selectedUserId = linkedSignal({
source: this.filteredUsers,
computation: (users, previous) => {
const previousId = previous?.value;
const stillExists = users.some(u => u.id === previousId);
return stillExists ? previousId! : users[0]?.id ?? null;
},
});
Pattern: filtres avec reset de pagination
// Pattern très courant: quand le filtre change, on revient à la page 1
@Component({ ... })
export class UserListComponent {
searchTerm = signal('');
sortBy = signal('name');
// La page se réinitialise à 1 quand searchTerm ou sortBy change
currentPage = linkedSignal(() => {
this.searchTerm(); // dépendance: trackée automatiquement
this.sortBy(); // dépendance: trackée automatiquement
return 1;
});
// L'utilisateur peut aussi changer la page manuellement
// page.set(3) fonctionne tant que searchTerm/sortBy ne change pas
filteredUsers = computed(() => {
const term = this.searchTerm().toLowerCase();
const page = this.currentPage();
const users = this.allUsers().filter(u => u.name.toLowerCase().includes(term));
return users.slice((page - 1) * 20, page * 20);
});
}
La resource() API
La resource() API (stable depuis Angular 19.2) est une abstraction pour gérer les données asynchrones dans les composants Angular. Elle modélise le cycle complet d'une requête: déclenchement, chargement, succès, erreur et rechargement — sans écrire de RxJS.
Structure complète d'une resource
import { Component, signal, resource } from '@angular/core';
interface User { id: number; name: string; email: string; }
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@switch (userResource.status()) {
@case ('loading') {
<div class="spinner">Chargement...</div>
}
@case ('error') {
<div class="alert alert-danger">
Erreur : {{ userResource.error()?.message }}
<button (click)="userResource.reload()">Réessayer</button>
</div>
}
@case ('resolved') {
<div>
<h2>{{ userResource.value()?.name }}</h2>
<p>{{ userResource.value()?.email }}</p>
<button (click)="refresh()">Rafraîchir</button>
</div>
}
}
`
})
export class UserProfileComponent {
userId = signal(1);
userResource = resource<User, { id: number }>({
// La "request" définit les paramètres — quand elle change, le loader se relance
request: () => ({ id: this.userId() }),
// Le "loader" est la fonction async qui charge les données
loader: async ({ request, abortSignal }) => {
// abortSignal annule la requête si une nouvelle request arrive
const response = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<User>;
},
});
// Forcer un rechargement sans changer la request
refresh() {
this.userResource.reload();
}
}
États et gestion des erreurs
Une resource expose un signal status() avec 6 états distincts, plus des raccourcis:
| Status | Description | value() | isLoading() |
|---|---|---|---|
idle |
Jamais chargé (request === undefined) | undefined | false |
loading |
Premier chargement en cours | undefined | true |
refreshing |
Rechargement (valeur précédente disponible) | ancienne valeur | true |
resolved |
Données chargées avec succès | données | false |
error |
Échec du chargement | undefined | false |
local |
Valeur définie manuellement via .set() |
valeur locale | false |
const r = resource({ ... });
// Signaux d'état
r.value() // T | undefined
r.status() // ResourceStatus (string union)
r.isLoading() // boolean — true pendant loading ET refreshing
r.error() // unknown — l'erreur si status === 'error'
// Actions
r.reload(); // force un rechargement (status → 'loading' ou 'refreshing')
r.set(newValue); // modifie la valeur localement (status → 'local')
r.update(fn); // met à jour basé sur la valeur actuelle
// Retry manuel avec délai exponentiel
async function retryResource(r: ResourceRef<any>, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
if (r.status() === 'error') {
await new Promise(res => setTimeout(res, Math.pow(2, i) * 1000));
r.reload();
} else break;
}
}
httpResource() et types génériques
httpResource() est une version de resource() optimisée pour HttpClient. Elle annule automatiquement les requêtes en cours quand la request change (via AbortController), supporte le typage générique complet et s'intègre avec les intercepteurs HTTP existants.
import { Component, signal, computed } from '@angular/core';
import { httpResource } from '@angular/common/http';
interface Post { id: number; title: string; body: string; userId: number; }
interface PaginatedResponse<T> { data: T[]; total: number; page: number; }
@Component({
selector: 'app-posts',
standalone: true,
template: `
<div class="filters">
<input [value]="search()" (input)="search.set($event.target.value)"
placeholder="Rechercher...">
<select (change)="perPage.set(+$event.target.value)">
<option value="10">10 par page</option>
<option value="25">25 par page</option>
</select>
</div>
@if (postsResource.isLoading()) {
<p>Chargement...</p>
}
@for (post of postsResource.value()?.data ?? []; track post.id) {
<article><h3>{{ post.title }}</h3></article>
}
<p>Total: {{ postsResource.value()?.total ?? 0 }} articles</p>
`
})
export class PostsComponent {
search = signal('');
page = linkedSignal(() => { this.search(); return 1; }); // reset sur search
perPage = signal(10);
// httpResource typé: PaginatedResponse<Post>
postsResource = httpResource<PaginatedResponse<Post>>(() => {
const params = new URLSearchParams({
q: this.search(),
page: String(this.page()),
limit: String(this.perPage()),
});
return `/api/posts?${params}`;
});
// Dériver des données calculées depuis la resource
totalPages = computed(() =>
Math.ceil((this.postsResource.value()?.total ?? 0) / this.perPage())
);
}
httpResource avec options avancées
// httpResource avec headers, méthode HTTP et transformation
posts = httpResource({
url: () => '/api/posts',
method: 'GET',
headers: () => ({ Authorization: `Bearer ${this.token()}` }),
// Transformer la réponse avant stockage
responseType: 'json',
});
// Accès aux headers de réponse
posts = httpResource(() => ({
url: `/api/data`,
observe: 'response', // retourne HttpResponse<T> complet
}));
Mises à jour optimistes avec resource
Le status local de resource permet des mises à jour optimistes: mettre à jour l'UI immédiatement (r.set()) puis synchroniser avec le serveur en arrière-plan, avec rollback en cas d'erreur.
@Component({ ... })
export class TodoListComponent {
todos = httpResource<Todo[]>(() => '/api/todos');
async toggleTodo(id: number) {
// 1. Mise à jour optimiste immédiate (status → 'local')
this.todos.update(todos =>
todos?.map(t => t.id === id ? { ...t, done: !t.done } : t) ?? []
);
// Sauvegarder l'état pour le rollback
const previousTodos = this.todos.value();
try {
// 2. Synchronisation avec le serveur
await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
// 3. Rechargement depuis le serveur pour confirmer
this.todos.reload();
} catch (error) {
// 4. Rollback en cas d'erreur serveur
this.todos.set(previousTodos ?? []);
console.error('Erreur lors du toggle:', error);
}
}
}
Comparaison: resource vs RxJS vs async pipe
| Aspect | resource() / httpResource() | RxJS (switchMap) | async pipe |
|---|---|---|---|
| Gestion des états | Intégrée (6 états) | Manuelle (BehaviorSubject) | Partielle (loading non inclus) |
| Annulation | Automatique (AbortController) | switchMap | takeUntilDestroyed |
| Retry | .reload() manuel |
retry(), retryWhen() | N/A |
| Mise à jour optimiste | .set() / .update() |
Complexe (startWith) | Non |
| Typage | Générique complet | Observable<T> | Observable<T> |
| Complexité | Faible | Élevée | Moyenne |
// ===== Avant Angular 19: avec RxJS =====
userId$ = new BehaviorSubject(1);
isLoading$ = new BehaviorSubject(false);
error$ = new BehaviorSubject<Error | null>(null);
user$ = this.userId$.pipe(
tap(() => this.isLoading$.next(true)),
switchMap(id => this.http.get<User>(`/api/users/${id}`).pipe(
catchError(err => {
this.error$.next(err);
return EMPTY;
}),
finalize(() => this.isLoading$.next(false))
)),
shareReplay(1)
);
// ===== Angular 19+: avec httpResource() =====
userId = signal(1);
// isLoading, error, value: tous inclus dans la resource
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
httpResource() pour les cas "fetch quand un signal change". Gardez RxJS pour les streams d'événements complexes, les combinaisons multi-sources (combineLatest, forkJoin), et les transformations avancées. Les deux coexistent parfaitement dans une même application.