Lab 19 : Signal Store
📖 Ressources
Section intitulée « 📖 Ressources »🚀 Code de départ
Section intitulée « 🚀 Code de départ »step-18-album-wholesale-v21-post-signal-form
Dans ce lab, vous allez implémenter une gestion d’état centralisée avec NgRx SignalStore. Vous installerez @ngrx/signals, définirez une interface d’état complète, créerez des valeurs calculées et implémenterez toutes les opérations CRUD comme méthodes asynchrones avec rxMethod.
📝 Instructions
Section intitulée « 📝 Instructions »Étape 1 : Installer @ngrx/signals
Section intitulée « Étape 1 : Installer @ngrx/signals »npm install @ngrx/signalsÉtape 2 : Définir l’interface d’état
Section intitulée « Étape 2 : Définir l’interface d’état »Créez src/app/store/album.store.ts. Commencez par définir une interface d’état complète — ne laissez pas de champs pour plus tard :
import { Album } from '../model/album.model';
interface AlbumState { albums: Album[]; selectedAlbum: Album | null; loading: boolean; loaded: boolean; error: string | null;}
const initialState: AlbumState = { albums: [], selectedAlbum: null, loading: false, loaded: false, error: null};loaded permet aux composants de savoir si le premier chargement est terminé. error stocke le dernier message d’erreur. selectedAlbum suit l’album actuellement ouvert dans une dialog ou une vue détail.
Étape 3 : Créer le store avec les valeurs calculées
Section intitulée « Étape 3 : Créer le store avec les valeurs calculées »import { computed } from '@angular/core';import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
export const AlbumStore = signalStore( { providedIn: 'root' },
withState(initialState),
withComputed((store) => ({ albumCount: computed(() => store.albums().length), hasAlbums: computed(() => store.albums().length > 0), isLoading: computed(() => store.loading()), isLoaded: computed(() => store.loaded()) })),);withComputed reçoit le store en paramètre — utilisez computed() pour dériver des valeurs réactives à partir des signaux.
Étape 4 : Ajouter les méthodes — Chargement et ajout
Section intitulée « Étape 4 : Ajouter les méthodes — Chargement et ajout »Ajoutez withMethods après withComputed. Injectez le service via la signature de fonction :
import { inject } from '@angular/core';import { rxMethod } from '@ngrx/signals/rxjs-interop';import { pipe, switchMap, tap } from 'rxjs';import { AlbumService } from '../services/album.service';
withMethods((store, albumService = inject(AlbumService)) => ({
loadAlbums: rxMethod<void>( pipe( tap(() => patchState(store, { loading: true, error: null })), switchMap(() => albumService.findAll()), tap({ next: (albums) => patchState(store, { albums, loading: false, loaded: true }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
addAlbum: rxMethod<Album>( pipe( tap(() => patchState(store, { loading: true })), switchMap((album) => albumService.save(album)), tap({ next: (newAlbum) => patchState(store, { albums: [...store.albums(), newAlbum], loading: false }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
}))Étape 5 : Ajouter les méthodes de mise à jour et de suppression
Section intitulée « Étape 5 : Ajouter les méthodes de mise à jour et de suppression »import { concatMap, finalize, map } from 'rxjs';
// dans withMethods, avec loadAlbums et addAlbum :
updateAlbum: rxMethod<Album>( pipe( tap(() => patchState(store, { loading: true })), switchMap((album) => albumService.update(album)), tap({ next: (updatedAlbum) => { const albums = store.albums().map(a => a.id === updatedAlbum.id ? updatedAlbum : a ); patchState(store, { albums, loading: false }); }, error: (error) => patchState(store, { error: error.message, loading: false }) }) )),
deleteAlbum: rxMethod<number>( pipe( tap(() => patchState(store, { loading: true, error: null })), concatMap((id) => albumService.delete(id).pipe( map(() => id), finalize(() => patchState(store, { loading: false })) ) ), tap({ next: (id) => { patchState(store, { albums: store.albums().filter(a => a.id !== id) }); }, error: (error) => patchState(store, { error: error?.message ?? 'Delete failed' }) }) )),finalize s’exécute quand l’observable interne se termine ou échoue — utile pour toujours réinitialiser loading, même en cas d’erreur.
Étape 6 : Ajouter les méthodes synchrones
Section intitulée « Étape 6 : Ajouter les méthodes synchrones »Toutes les méthodes n’ont pas besoin de rxMethod. Les mutations d’état simples sont de simples fonctions :
selectAlbum(album: Album | null) { patchState(store, { selectedAlbum: album });},
clearError() { patchState(store, { error: null });}Celles-ci sont appelées directement — pas d’observable, pas de pipe, pas de tap.
Étape 7 : Utiliser le store dans un composant
Section intitulée « Étape 7 : Utiliser le store dans un composant »Injectez le store et exposez ses signaux directement comme propriétés du composant :
import { Component, inject } from '@angular/core';import { AlbumStore } from '../../../store/album.store';
export class AlbumListComponent { private albumStore = inject(AlbumStore);
// Exposition des signaux — le template les appellera albums = this.albumStore.albums; loading = this.albumStore.loading; albumCount = this.albumStore.albumCount;
constructor() { this.albumStore.loadAlbums(); }
addAlbum(album: Album) { this.albumStore.addAlbum(album); }
deleteAlbum(id: number) { this.albumStore.deleteAlbum(id); }}Template :
@if (loading()) { <p>Chargement...</p>}
<p>{{ albumCount() }} albums</p>
@for (album of albums(); track album.id) { <app-album-card [album]="album" (delete)="deleteAlbum(album.id)" />}Étape 8 : Déclencher addAlbum depuis une dialog
Section intitulée « Étape 8 : Déclencher addAlbum depuis une dialog »La dialog d’ajout injecte le store directement et appelle addAlbum quand l’utilisateur choisit un album :
export class AddComponent { private albumStore = inject(AlbumStore); private dialogRef = inject(MatDialog);
// Albums déjà dans le store (pour filtrer les doublons) albums = this.albumStore.albums;
makeItAvailable(album: Album) { this.albumStore.addAlbum(album); // le store gère le save + la mise à jour de l'état this.dialogRef.closeAll(); }}C’est l’avantage clé du SignalStore : n’importe quel composant injecte le même store singleton — pas de prop drilling, pas de chaînes @Output vers le parent.
🧩 Store complet — référence
Section intitulée « 🧩 Store complet — référence »import { computed, inject } from '@angular/core';import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';import { rxMethod } from '@ngrx/signals/rxjs-interop';import { concatMap, finalize, map, pipe, switchMap, tap } from 'rxjs';import { AlbumService } from '../services/album.service';import { Album } from '../model/album.model';
interface AlbumState { albums: Album[]; selectedAlbum: Album | null; loading: boolean; loaded: boolean; error: string | null;}
const initialState: AlbumState = { albums: [], selectedAlbum: null, loading: false, loaded: false, error: null};
export const AlbumStore = signalStore( { providedIn: 'root' }, withState(initialState),
withComputed((store) => ({ albumCount: computed(() => store.albums().length), hasAlbums: computed(() => store.albums().length > 0), isLoading: computed(() => store.loading()), isLoaded: computed(() => store.loaded()) })),
withMethods((store, albumService = inject(AlbumService)) => ({
loadAlbums: rxMethod<void>( pipe( tap(() => patchState(store, { loading: true, error: null })), switchMap(() => albumService.findAll()), tap({ next: (albums) => patchState(store, { albums, loading: false, loaded: true }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
addAlbum: rxMethod<Album>( pipe( tap(() => patchState(store, { loading: true })), switchMap((album) => albumService.save(album)), tap({ next: (newAlbum) => patchState(store, { albums: [...store.albums(), newAlbum], loading: false }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
updateAlbum: rxMethod<Album>( pipe( tap(() => patchState(store, { loading: true })), switchMap((album) => albumService.update(album)), tap({ next: (updatedAlbum) => { const albums = store.albums().map(a => a.id === updatedAlbum.id ? updatedAlbum : a ); patchState(store, { albums, loading: false }); }, error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
deleteAlbum: rxMethod<number>( pipe( tap(() => patchState(store, { loading: true, error: null })), concatMap((id) => albumService.delete(id).pipe( map(() => id), finalize(() => patchState(store, { loading: false })) ) ), tap({ next: (id) => { patchState(store, { albums: store.albums().filter(a => a.id !== id) }); }, error: (error) => patchState(store, { error: error?.message ?? 'Delete failed' }) }) ) ),
selectAlbum(album: Album | null) { patchState(store, { selectedAlbum: album }); },
clearError() { patchState(store, { error: null }); } })));
