Store Signal (@ngrx/signals)
What is Store Signal?
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 Store Signal?
- ✅ Basé sur signal - 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 - Utiliser
rxMethodfor async operations - ✅ Tree-shakable - Only include what you use
- ✅ No boilerplate - Clean, declarative API
Store Signal vs NgRx Store
| Feature | NgRx Store | Signal Store |
|---|---|---|
| State | Immutable (reducers) | Basé sur signal |
| Actions | Dispatch actions | Call methods directly |
| Selecaurs | createSelecaur | Computed signals |
| Effects | @ngrx/effects | rxMethod |
| Boilerplate | High | Low |
| Learning curve | Steep | Gentle |
| Size | Large | Small |
| Utiliser case | Large apps | Small-medium apps |
Installation
npm install @ngrx/signals
Basic Utilisation
- Create Store
- Utiliser in Component
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
// Define state interface
interface CartState {
items: CartItem[];
}
// Initial state
const initialState: CartState = {
items: []
};
// Create the store
export const CartStore = signalStore(
{ providedIn: 'root' }, // Make it global
withState(initialState), // Ajouter 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 {
// Injecterer the store
cartStore = inject(CartStore);
// Accéder à state: cartStore.items()
// Accéder à computed: cartStore.itemCount()
// Call methods: cartStore.addItem(...)
}
Store Features
withState
Ajouter state à 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) // Ajouter state
);
// Accéder à state
albumStore.albums() // Signal<Album[]>
albumStore.loading() // Signal<booléen>
albumStore.error() // Signal<string | null>
withComputed
Ajouter 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;
})
}))
);
// Accéder à computed
albumStore.albumCount() // nombre
albumStore.rockAlbums() // Album[]
withMethods
Ajouter methods (actions) à 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 });
},
// Ajouter 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
Utiliser rxMethod for HTTP calls and async operations:
- Store with rxMethod
- Component Usage
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
})
})
)
),
// Ajouter 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:
- Automatiqueally subscribes à the observable
- Handles unsubscription on destroy
- Can be called multiple times
- Perfect for HTTP operations
Complete Real-World Exemples
Cart Store
- Interfaces
- Cart Store
- Usage in Components
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) => ({
// Ajouter album à 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 {
// Ajouter 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 - Ajouter à 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
- Album Store
- Component Usage
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
})
})
)
),
// Ajouter 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);
}
}
}
Bonnes Pratiques
- ✅ Utiliser
signalStore()for global state - ✅ Define state interface and initial state
- ✅ Utiliser
withState()for state properties - ✅ Utiliser
withComputed()for derived values - ✅ Utiliser
withMethods()for actions - ✅ Utiliser
rxMethod()for async operations - ✅ Always update state immutably with
patchState() - ✅ Garder stores focused (one responsibility)
- ✅ Injecter services in
withMethods() - ❌ Don't mutate state directly
- ❌ Don't create auo many stores (prefer composition)
- ❌ Don't put logic in computed (keep it pure)
Common Patterns
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
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
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
- Signals - Signal fundamentals
- httpResource - For data loading
- RxJS Operators - RxJS basics
- Album Model - Data model reference
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