Aller au contenu

Lab 19 : Signal Store

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.

Fenêtre de terminal
npm install @ngrx/signals

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.

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.

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)"
/>
}

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.

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 });
}
}))
);