Lab 19: Signal Store
π Resourcesβ
π 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β
Step 1: Install @ngrx/signalsβ
npm install @ngrx/signals
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β
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β
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 })
})
)
),
}))
switchMap cancels any in-flight request when a new one arrives. This is correct for load and add β if the user triggers the action twice, only the last matters.
For delete, you will use concatMap instead β deletes must complete in order. Cancelling a delete mid-flight could leave the server and the UI out of sync.
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β
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β
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β
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β
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 });
}
}))
);