Skip to main content

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 vs concatMap

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