Signal Store (@ngrx/signals)
What is Signal Store?
Section titled “What is Signal Store?”Signal Store is a lightweight state management library from @ngrx/signals that provides signal-based global state for Angular applications. It’s simpler than NgRx Store but more powerful than component-level signals.
Why Use Signal Store?
Section titled “Why Use Signal Store?”- ✅ Signal-based - Built on Angular signals
- ✅ Lightweight - Much simpler than NgRx Store
- ✅ Type-safe - Full TypeScript support
- ✅ Composable - Build stores with features (
withState,withMethods,withComputed) - ✅ RxJS interop - Use
rxMethodfor async operations - ✅ Tree-shakable - Only include what you use
- ✅ No boilerplate - Clean, declarative API
Signal Store vs NgRx Store
Section titled “Signal Store vs NgRx Store”| Feature | NgRx Store | Signal Store |
|---|---|---|
| State | Immutable (reducers) | Signal-based |
| Actions | Dispatch actions | Call methods directly |
| Selectors | createSelector | Computed signals |
| Effects | @ngrx/effects | rxMethod |
| Boilerplate | High | Low |
| Learning curve | Steep | Gentle |
| Size | Large | Small |
| Use case | Large apps | Small-medium apps |
Installation
Section titled “Installation”npm install @ngrx/signalsBasic Usage
Section titled “Basic Usage”import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';import { computed } from '@angular/core';
// Define state interfaceinterface CartState { items: CartItem[];}
// Initial stateconst initialState: CartState = { items: []};
// Create the storeexport const CartStore = signalStore( { providedIn: 'root' }, // Make it global withState(initialState), // Add state
withComputed((store) => ({ // Computed signals itemCount: computed(() => store.items().length), total: computed(() => store.items().reduce((sum, item) => sum + item.price, 0) ), isEmpty: computed(() => store.items().length === 0) })),
withMethods((store) => ({ // Actions addItem(item: CartItem) { patchState(store, { items: [...store.items(), item] }); },
removeItem(id: number) { patchState(store, { items: store.items().filter(i => i.id !== id) }); },
clear() { patchState(store, { items: [] }); } })));import { Component, inject } from '@angular/core';import { CartStore } from './store/cart.store';
@Component({ selector: 'app-cart', standalone: true, template: ` <h2>Shopping Cart</h2>
<p>Items: {{ cartStore.itemCount() }}</p> <p>Total: {{ cartStore.total() | currency }}</p>
@if (cartStore.isEmpty()) { <p>Cart is empty</p> } @else { @for (item of cartStore.items(); track item.id) { <div class="cart-item"> <span>{{ item.name }}</span> <button (click)="cartStore.removeItem(item.id)"> Remove </button> </div> } <button (click)="cartStore.clear()">Clear Cart</button> } `})export class CartComponent { // Inject the store cartStore = inject(CartStore);
// Access state: cartStore.items() // Access computed: cartStore.itemCount() // Call methods: cartStore.addItem(...)}Store Features
Section titled “Store Features”withState
Section titled “withState”Add state to your store:
interface AlbumState { albums: Album[]; loading: boolean; error: string | null;}
const initialState: AlbumState = { albums: [], loading: false, error: null};
export const AlbumStore = signalStore( { providedIn: 'root' }, withState(initialState) // Add state);
// Access statealbumStore.albums() // Signal<Album[]>albumStore.loading() // Signal<boolean>albumStore.error() // Signal<string | null>withComputed
Section titled “withComputed”Add computed signals (derived state):
export const AlbumStore = signalStore( { providedIn: 'root' }, withState(initialState),
withComputed((store) => ({ // Computed from state albumCount: computed(() => store.albums().length), hasAlbums: computed(() => store.albums().length > 0), isLoading: computed(() => store.loading()),
// Complex computations rockAlbums: computed(() => store.albums().filter(a => a.tags.includes('Rock')) ),
averagePrice: computed(() => { const albums = store.albums(); if (albums.length === 0) return 0; return albums.reduce((sum, a) => sum + a.price, 0) / albums.length; }) })));
// Access computedalbumStore.albumCount() // numberalbumStore.rockAlbums() // Album[]withMethods
Section titled “withMethods”Add methods (actions) to modify state:
import { patchState } from '@ngrx/signals';
export const AlbumStore = signalStore( { providedIn: 'root' }, withState(initialState),
withMethods((store) => ({ // Simple state updates setLoading(loading: boolean) { patchState(store, { loading }); },
setError(error: string | null) { patchState(store, { error }); },
// Add album addAlbum(album: Album) { patchState(store, { albums: [...store.albums(), album] }); },
// Update album updateAlbum(updatedAlbum: Album) { const albums = store.albums().map(a => a.id === updatedAlbum.id ? updatedAlbum : a ); patchState(store, { albums }); },
// Remove album removeAlbum(id: number) { patchState(store, { albums: store.albums().filter(a => a.id !== id) }); },
// Clear all reset() { patchState(store, initialState); } })));Async Operations with rxMethod
Section titled “Async Operations with rxMethod”Use rxMethod for HTTP calls and async operations:
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';import { rxMethod } from '@ngrx/signals/rxjs-interop';import { inject } from '@angular/core';import { AlbumService } from './album.service';import { pipe, switchMap, tap } from 'rxjs';
interface AlbumState { albums: Album[]; loading: boolean; error: string | null;}
export const AlbumStore = signalStore( { providedIn: 'root' }, withState<AlbumState>({ albums: [], loading: false, error: null }),
withMethods((store, albumService = inject(AlbumService)) => ({ // Load all albums loadAlbums: rxMethod<void>( pipe( tap(() => patchState(store, { loading: true, error: null })), switchMap(() => albumService.findAll()), tap({ next: (albums) => patchState(store, { albums, loading: false }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ),
// Add album 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 }) }) ) ),
// Delete album deleteAlbum: rxMethod<number>( pipe( tap(() => patchState(store, { loading: true })), switchMap((id) => albumService.delete(id).pipe( map(() => id) )), tap({ next: (id) => patchState(store, { albums: store.albums().filter(a => a.id !== id), loading: false }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) ) })));@Component({ selector: 'app-album-list', standalone: true, template: ` @if (albumStore.loading()) { <p>Loading...</p> }
@if (albumStore.error(); as err) { <p class="error">Error: {{ err }}</p> }
@for (album of albumStore.albums(); track album.id) { <div class="album-card"> <h3>{{ album.name }}</h3> <p>{{ album.artist }}</p> <button (click)="deleteAlbum(album.id)">Delete</button> </div> } `})export class AlbumListComponent implements OnInit { albumStore = inject(AlbumStore);
ngOnInit() { // Load albums on init this.albumStore.loadAlbums(); }
deleteAlbum(id: number) { this.albumStore.deleteAlbum(id); }}rxMethod behavior:
- Automatically subscribes to the observable
- Handles unsubscription on destroy
- Can be called multiple times
- Perfect for HTTP operations
Complete Real-World Examples
Section titled “Complete Real-World Examples”Cart Store
Section titled “Cart Store”import { Album } from '../models/album.model';
export interface CartItem { album: Album; quantity: number;}
interface CartState { items: CartItem[];}import { computed } from '@angular/core';import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
const initialState: CartState = { items: []};
export const CartStore = signalStore( { providedIn: 'root' }, withState(initialState),
withComputed((store) => ({ // Get quantity for specific album getQuantityInCart: computed(() => (id: number) => store.items().find(item => item.album.id === id)?.quantity ?? 0 ),
// Total items totalItems: computed(() => store.items().reduce((sum, item) => sum + item.quantity, 0) ),
// Total price totalPrice: computed(() => store.items().reduce((sum, item) => sum + (item.album.price * item.quantity), 0 ) ),
// Is empty isEmpty: computed(() => store.items().length === 0) })),
withMethods((store) => ({ // Add album to cart add(album: Album) { const currentItems = store.items(); const existingIndex = currentItems.findIndex( item => item.album.id === album.id );
if (existingIndex >= 0) { // Increment quantity const updatedItems = currentItems.map((item, i) => i === existingIndex ? { ...item, quantity: item.quantity + 1 } : item ); patchState(store, { items: updatedItems }); } else { // Add new item patchState(store, { items: [...currentItems, { album, quantity: 1 }] }); } },
// Remove one quantity (or remove item if quantity = 0) remove(albumId: number) { const items = store.items() .map(item => item.album.id === albumId ? { ...item, quantity: item.quantity - 1 } : item ) .filter(item => item.quantity > 0);
patchState(store, { items }); },
// Clear cart clear() { patchState(store, { items: [] }); } })));// Album List Component - Add to cart@Component({ selector: 'app-album-list', template: ` @for (album of albums(); track album.id) { <div class="album-card"> <h3>{{ album.name }}</h3> <p>{{ album.price | currency }}</p>
<button (click)="addToCart(album)"> Add to Cart @if (quantityInCart()(album.id) > 0) { <span class="badge">{{ quantityInCart()(album.id) }}</span> } </button> </div> } `})export class AlbumListComponent { private cartStore = inject(CartStore);
quantityInCart = this.cartStore.getQuantityInCart;
addToCart(album: Album) { this.cartStore.add(album); }}
// Cart Component - Display cart@Component({ selector: 'app-cart', template: ` <h2>Shopping Cart</h2>
@if (cartStore.isEmpty()) { <p>Your cart is empty</p> } @else { <div class="cart-summary"> <p>Total Items: {{ cartStore.totalItems() }}</p> <p>Total: {{ cartStore.totalPrice() | currency }}</p> </div>
@for (item of cartStore.items(); track item.album.id) { <div class="cart-item"> <span>{{ item.album.name }}</span> <span>{{ item.quantity }} x {{ item.album.price | currency }}</span> <button (click)="cartStore.remove(item.album.id)">-</button> <button (click)="cartStore.add(item.album)">+</button> </div> }
<button (click)="cartStore.clear()">Clear Cart</button> } `})export class CartComponent { cartStore = inject(CartStore);}Album Store with HTTP
Section titled “Album Store with HTTP”import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';import { rxMethod } from '@ngrx/signals/rxjs-interop';import { inject, computed } from '@angular/core';import { AlbumService } from '../services/album.service';import { Album } from '../models/album.model';import { pipe, switchMap, tap, map, concatMap, finalize } from 'rxjs';
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()),
// Filtered lists highlightedAlbums: computed(() => store.albums().filter(a => a.highlighted) ),
albumsByTag: computed(() => (tag: string) => store.albums().filter(a => a.tags.includes(tag)) ) })),
withMethods((store, albumService = inject(AlbumService)) => ({ // Load all albums 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 }) }) ) ),
// Add album 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 }) }) ) ),
// Update album 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 }) }) ) ),
// Delete album 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' }) }) ) ),
// Select album selectAlbum(album: Album | null) { patchState(store, { selectedAlbum: album }); },
// Clear error clearError() { patchState(store, { error: null }); } })));@Component({ selector: 'app-album-list', standalone: true, template: ` <h1>Albums ({{ albumStore.albumCount() }})</h1>
@if (albumStore.isLoading()) { <div class="loading">Loading albums...</div> }
@if (albumStore.error(); as err) { <div class="error"> <p>Error: {{ err }}</p> <button (click)="albumStore.clearError()">Dismiss</button> </div> }
@if (albumStore.hasAlbums()) { <div class="album-grid"> @for (album of albumStore.albums(); track album.id) { <div class="album-card"> <h3>{{ album.name }}</h3> <p>{{ album.artist }}</p> <p class="price">{{ album.price | currency }}</p> <button (click)="deleteAlbum(album.id)">Delete</button> </div> } </div> } @else if (!albumStore.isLoading()) { <p>No albums found</p> } `})export class AlbumListComponent implements OnInit { albumStore = inject(AlbumStore);
ngOnInit() { // Load albums on init if (!this.albumStore.isLoaded()) { this.albumStore.loadAlbums(); } }
deleteAlbum(id: number) { if (confirm('Delete this album?')) { this.albumStore.deleteAlbum(id); } }}Best Practices
Section titled “Best Practices”- ✅ Use
signalStore()for global state - ✅ Define state interface and initial state
- ✅ Use
withState()for state properties - ✅ Use
withComputed()for derived values - ✅ Use
withMethods()for actions - ✅ Use
rxMethod()for async operations - ✅ Always update state immutably with
patchState() - ✅ Keep stores focused (one responsibility)
- ✅ Inject services in
withMethods() - ❌ Don’t mutate state directly
- ❌ Don’t create too many stores (prefer composition)
- ❌ Don’t put logic in computed (keep it pure)
Common Patterns
Section titled “Common Patterns”Pattern 1: Loading State
Section titled “Pattern 1: Loading State”withMethods((store, service = inject(Service)) => ({ loadData: rxMethod<void>( pipe( tap(() => patchState(store, { loading: true, error: null })), switchMap(() => service.getData()), tap({ next: (data) => patchState(store, { data, loading: false }), error: (error) => patchState(store, { error: error.message, loading: false }) }) ) )}))Pattern 2: Optimistic Updates
Section titled “Pattern 2: Optimistic Updates”deleteItem: rxMethod<number>( pipe( // Optimistic update tap((id) => patchState(store, { items: store.items().filter(i => i.id !== id) })), switchMap((id) => service.delete(id)), tap({ error: (error) => { // Rollback on error console.error('Delete failed, restoring item'); this.loadItems(); // Reload from server } }) ))Pattern 3: Pagination
Section titled “Pattern 3: Pagination”interface State { items: Item[]; page: number; pageSize: number; total: number;}
withComputed((store) => ({ paginatedItems: computed(() => { const start = store.page() * store.pageSize(); const end = start + store.pageSize(); return store.items().slice(start, end); }), totalPages: computed(() => Math.ceil(store.total() / store.pageSize()) )})),
withMethods((store) => ({ nextPage() { patchState(store, { page: store.page() + 1 }); }, previousPage() { patchState(store, { page: Math.max(0, store.page() - 1) }); }}))Related Documentation
Section titled “Related Documentation”- Signals - Signal fundamentals
- httpResource - For data loading
- RxJS Operators - RxJS basics
- Album Model - Data model reference
Project Reference
Section titled “Project Reference”See this pattern in action:
- Cart Store:
src/app/store/cart.store.ts - Album Store:
src/app/store/album.store.ts - Component:
src/app/components/albums/album-list/album-list.component.ts - Learning Path: Day 3, Module 3.8 - Signal Store
Last Updated: December 2024 Package: @ngrx/signals Angular Version: 16+ Status: Recommended for modern Angular apps

