Lab 19: Signal Store
📖 Resources
Section titled “📖 Resources”🚀 Starter Code
Section titled “🚀 Starter Code”step-18-album-wholesale-v21-post-signal-form
In this lab, you’ll implement centralized state management using NgRx SignalStore. You’ll install @ngrx/signals, define a full state interface, create computed values, and implement all CRUD operations as async methods using rxMethod.
📝 Instructions
Section titled “📝 Instructions”Step 1: Install @ngrx/signals
Section titled “Step 1: Install @ngrx/signals”npm install @ngrx/signalsStep 2: Define the State Interface
Section titled “Step 2: Define the State Interface”Create src/app/store/album.store.ts. Start by defining a complete state interface — don’t leave out fields you’ll need later:
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 lets components know the first fetch is done. error holds the last error message. selectedAlbum tracks the album currently open in a dialog or detail view.
Step 3: Create the Store with Computed Values
Section titled “Step 3: Create the Store with Computed Values”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 receives the store slice — use computed() to derive reactive values from signals.
Step 4: Add Methods — Load and Add
Section titled “Step 4: Add Methods — Load and Add”Add withMethods after withComputed. Inject the service via the function signature:
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 }) }) ) ),
}))Step 5: Add Update and Delete Methods
Section titled “Step 5: Add Update and Delete Methods”import { concatMap, finalize, map } from 'rxjs';
// inside withMethods, alongside loadAlbums and 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 runs when the inner observable completes or errors — useful to always reset loading, even on failure.
Step 6: Add Synchronous Methods
Section titled “Step 6: Add Synchronous Methods”Not every method needs rxMethod. Simple state mutations are plain functions:
selectAlbum(album: Album | null) { patchState(store, { selectedAlbum: album });},
clearError() { patchState(store, { error: null });}These are called directly — no observable, no pipe, no tap.
Step 7: Use the Store in a Component
Section titled “Step 7: Use the Store in a Component”Inject the store and expose its signals directly as component properties:
import { Component, inject } from '@angular/core';import { AlbumStore } from '../../../store/album.store';
export class AlbumListComponent { private albumStore = inject(AlbumStore);
// Expose signals — no need to call them here, templates call them 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>Loading...</p>}
<p>{{ albumCount() }} albums</p>
@for (album of albums(); track album.id) { <app-album-card [album]="album" (delete)="deleteAlbum(album.id)" />}Step 8: Trigger addAlbum from a Dialog
Section titled “Step 8: Trigger addAlbum from a Dialog”The add dialog injects the store directly and calls addAlbum when a user picks an album:
export class AddComponent { private albumStore = inject(AlbumStore); private dialogRef = inject(MatDialog);
// Albums already in the store (to filter duplicates) albums = this.albumStore.albums;
makeItAvailable(album: Album) { this.albumStore.addAlbum(album); // store handles save + state update this.dialogRef.closeAll(); }}This is the key SignalStore benefit: any component injects the same singleton store — no prop drilling, no @Output chains to the parent.
🧩 Complete Store Reference
Section titled “🧩 Complete Store Reference”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 }); } })));
