Aller au contenu principal

Lab 19 : Signal Store

📖 Ressources​

🚀 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​

Étape 1 : Installer @ngrx/signals​

npm install @ngrx/signals

É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​

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​

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

}))
switchMap vs concatMap

switchMap annule toute requête en cours quand une nouvelle arrive. C'est correct pour charger et ajouter — si l'utilisateur déclenche l'action deux fois, seule la dernière compte.

Pour supprimer, vous utiliserez concatMap — les suppressions doivent se terminer dans l'ordre. Annuler une suppression en cours pourrait désynchroniser le serveur et l'UI.

É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​

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​

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​

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​

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