NgRx Signal Store (@ngrx/signals) : signalStore(), withState(), withMethods(), withComputed() — guide complet state management Angular 19+.
Pourquoi Signal Store ?
Depuis Angular 16, les Signals sont le nouveau modèle de réactivité d'Angular. Ils remplacent progressivement les Observables RxJS pour la gestion d'état local et global. NgRx a pris acte de cette évolution avec @ngrx/signals, une bibliothèque de state management entièrement basée sur les Signals — sans Actions, sans Reducers, sans Effects RxJS.
Avant d'entrer dans le vif du sujet, il est utile de comprendre pourquoi NgRx Signal Store existe à côté du NgRx classique (Store + Actions + Reducers + Effects).
| Critère | NgRx Store classique | NgRx Signal Store |
|---|---|---|
| Courbe d'apprentissage | Elevée (Actions, Reducers, Effects, Selectors) | Faible (signalStore, withState, withMethods) |
| Boilerplate | Important (4-5 fichiers par feature) | Minimal (1 fichier suffit) |
| Typage TypeScript | Partiel (actions non typées nativement) | Complet et inféré automatiquement |
| Réactivité | Observable / RxJS | Signals Angular natifs |
| DevTools | Redux DevTools complet | Support partiel (en évolution) |
| Cas d'usage idéal | Applications enterprise très complexes | Applications modernes Angular 17+ |
Comparaison de code : avant et après
Voici un compteur simple implémenté avec les deux approches pour illustrer le gain de concision :
// ❌ AVANT — NgRx classique (3 fichiers, ~60 lignes)
// counter.actions.ts
import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { increment, decrement } from './counter.actions';
export const counterReducer = createReducer(
{ count: 0 },
on(increment, state => ({ count: state.count + 1 })),
on(decrement, state => ({ count: state.count - 1 }))
);
// counter.component.ts
this.store.dispatch(increment()); // Dispatcher une action
this.count$ = this.store.select(selectCount); // Observable à gérer
// ✅ APRÈS — NgRx Signal Store (1 fichier, ~15 lignes)
// counter.store.ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
export const CounterStore = signalStore(
{ providedIn: 'root' }, // Disponible globalement comme un service Angular
withState({ count: 0 }), // État initial typé automatiquement
withMethods(store => ({
// Méthode increment : met à jour l'état avec patchState
increment() { patchState(store, { count: store.count() + 1 }); },
// Méthode decrement : décrémente de la même façon
decrement() { patchState(store, { count: store.count() - 1 }); }
}))
);
// counter.component.ts
const store = inject(CounterStore);
store.increment(); // Appel direct — aucune action à dispatcher
store.count(); // Signal — pas d'Observable, pas d'async pipe
Le résultat est identique fonctionnellement, mais Signal Store est 4x plus concis et entièrement typé sans configuration supplémentaire.
Installation et configuration
NgRx Signal Store fait partie du package @ngrx/signals, disponible depuis NgRx 17. Il est compatible avec Angular 17+ et fonctionne parfaitement avec Angular 19 et ses Signals stabilisés.
Prérequis de versions
| Package | Version minimale | Version recommandée |
|---|---|---|
| Angular | 17.0.0 | 19.x |
| @ngrx/signals | 17.0.0 | 19.x (compatible Angular 19) |
| TypeScript | 5.2 | 5.4+ |
| Node.js | 18.x | 20.x (LTS) |
Installation via npm
# Installer @ngrx/signals dans un projet Angular existant
npm install @ngrx/signals
# Ou avec une version précise pour Angular 19
npm install @ngrx/signals@19
@ngrx/signals ne nécessite aucun module NgRx à importer dans app.config.ts. Pas de provideStore(), pas de provideEffects() — le Signal Store s'injecte comme un service Angular ordinaire.
Structure d'un projet avec Signal Store
// Organisation recommandée des stores dans un projet Angular
src/
├── app/
│ ├── stores/
│ │ ├── counter.store.ts // Store simple (compteur)
│ │ ├── cart.store.ts // Store panier e-commerce
│ │ └── auth.store.ts // Store authentification
│ ├── features/
│ │ ├── products/
│ │ │ ├── product.store.ts // Store feature (scoped)
│ │ │ └── product.component.ts
│ │ └── orders/
│ │ └── order.store.ts
│ └── app.config.ts
Aucune configuration app.config.ts requise
// app.config.ts — configuration minimale pour Signal Store
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
// ✅ Aucun provideStore() ou provideEffects() requis
// Les Signal Stores s'injectent directement avec inject()
]
};
Créer son premier Store
signalStore() est la fonction centrale de @ngrx/signals. Elle crée une classe Angular injectable qui expose des Signals en lecture seule pour chaque propriété de l'état. withState() définit la forme et les valeurs initiales de cet état.
Anatomie d'un Signal Store
// src/app/stores/todo.store.ts
import { signalStore, withState, patchState } from '@ngrx/signals';
// 1. Définir l'interface de l'état (TypeScript strict)
interface TodoState {
todos: Todo[]; // Liste des tâches
isLoading: boolean; // Indicateur de chargement
filter: 'all' | 'done' | 'pending'; // Filtre actif
selectedId: number | null; // ID sélectionné
}
// 2. Définir la structure d'une tâche
interface Todo {
id: number;
title: string;
completed: boolean;
}
// 3. État initial du store
const initialState: TodoState = {
todos: [],
isLoading: false,
filter: 'all',
selectedId: null
};
// 4. Créer le store global (providedIn: 'root' = singleton)
export const TodoStore = signalStore(
{ providedIn: 'root' }, // Optionnel : omettez pour un store scoped par composant
withState(initialState) // Injecte l'état initial typé
// → Angular génère automatiquement les Signals :
// store.todos() → Signal<Todo[]>
// store.isLoading() → Signal<boolean>
// store.filter() → Signal<'all' | 'done' | 'pending'>
// store.selectedId()→ Signal<number | null>
);
Utiliser le store dans un composant
// src/app/features/todos/todo-list.component.ts
import { Component, inject } from '@angular/core';
import { TodoStore } from '../../stores/todo.store';
@Component({
selector: 'app-todo-list',
standalone: true,
template: `
<div class="todo-container">
<!-- Afficher l'indicateur de chargement -->
@if (store.isLoading()) {
<p>Chargement...</p>
}
<!-- Itérer sur les todos (Signal mis à jour automatiquement) -->
@for (todo of store.todos(); track todo.id) {
<div class="todo-item">
<span [class.done]="todo.completed">{{ todo.title }}</span>
</div>
}
<!-- Afficher le filtre actif -->
<p>Filtre actif : {{ store.filter() }}</p>
</div>
`
})
export class TodoListComponent {
// Injecter le store comme n'importe quel service Angular
protected readonly store = inject(TodoStore);
// store.todos() est un Signal<Todo[]> — lecture directe, sans async pipe
}
Store scoped par composant (non singleton)
// Store sans { providedIn: 'root' } → doit être fourni par le composant
export const LocalCounterStore = signalStore(
// Pas de { providedIn: 'root' } → ce store n'est pas global
withState({ count: 0, step: 1 })
);
// Composant qui fournit son propre store isolé
@Component({
selector: 'app-counter',
standalone: true,
providers: [LocalCounterStore], // ← Le store est créé pour CE composant uniquement
template: `<p>Compteur : {{ store.count() }}</p>`
})
export class CounterComponent {
protected readonly store = inject(LocalCounterStore);
// Chaque instance de CounterComponent a son propre store indépendant
}
{ providedIn: 'root' } crée un store singleton partagé par toute l'application (comme un service root). Sans cette option, le store est scoped au composant qui le fournit — parfait pour les widgets autonomes ou les composants formulaire complexes.
Méthodes et computed signals
withMethods() et withComputed() sont les deux briques qui donnent vie au store. Les méthodes encapsulent la logique métier qui modifie l'état. Les computed signals dérivent automatiquement des valeurs depuis l'état existant.
withMethods() — définir les actions du store
// src/app/stores/todo.store.ts — version complète avec méthodes
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods(store => ({
// Ajouter une nouvelle tâche à la liste
addTodo(title: string): void {
// Créer un nouvel objet Todo avec un ID unique basé sur timestamp
const newTodo: Todo = {
id: Date.now(),
title: title.trim(),
completed: false
};
// patchState met à jour l'état de façon immutable
patchState(store, state => ({
// Spread de la liste existante + ajout du nouvel élément
todos: [...state.todos, newTodo]
}));
},
// Basculer l'état completed d'une tâche par son ID
toggleTodo(id: number): void {
patchState(store, state => ({
todos: state.todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // Inverser completed
: todo // Laisser les autres tâches inchangées
)
}));
},
// Supprimer une tâche par son ID
removeTodo(id: number): void {
patchState(store, state => ({
// Filtrer la liste en excluant l'ID ciblé
todos: state.todos.filter(todo => todo.id !== id)
}));
},
// Changer le filtre actif
setFilter(filter: 'all' | 'done' | 'pending'): void {
// Mise à jour simple d'une propriété scalaire
patchState(store, { filter });
},
// Réinitialiser entièrement le store à son état initial
reset(): void {
patchState(store, initialState);
}
}))
);
withComputed() — dériver des valeurs calculées
// Importer computed depuis @angular/core pour les Signals dérivés
import { computed } from '@angular/core';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods(/* ... méthodes ci-dessus ... */),
withComputed(store => ({
// Nombre total de tâches — recalculé automatiquement si todos change
totalCount: computed(() => store.todos().length),
// Nombre de tâches complètes
completedCount: computed(() =>
store.todos().filter(t => t.completed).length
),
// Nombre de tâches en attente
pendingCount: computed(() =>
store.todos().filter(t => !t.completed).length
),
// Pourcentage de complétion (0-100)
completionPercent: computed(() => {
const total = store.todos().length;
if (total === 0) return 0; // Éviter la division par zéro
const completed = store.todos().filter(t => t.completed).length;
return Math.round((completed / total) * 100);
}),
// Liste filtrée selon le filtre actif — Signal réactif combiné
filteredTodos: computed(() => {
const todos = store.todos(); // Dépendance 1
const filter = store.filter(); // Dépendance 2
// Angular re-calcule automatiquement si l'un des deux change
switch (filter) {
case 'done': return todos.filter(t => t.completed);
case 'pending': return todos.filter(t => !t.completed);
default: return todos; // 'all' — retourner tout
}
}),
// Indique si la liste est vide
isEmpty: computed(() => store.todos().length === 0)
}))
);
Utiliser méthodes et computed dans le composant
// todo-list.component.ts — utilisation complète
@Component({
selector: 'app-todo-list',
standalone: true,
template: `
<!-- Barre de progression dynamique via computed signal -->
<div class="progress mb-3">
<div class="progress-bar"
[style.width.%]="store.completionPercent()"
role="progressbar"
[attr.aria-valuenow]="store.completionPercent()"
aria-valuemin="0" aria-valuemax="100">
{{ store.completionPercent() }}%
</div>
</div>
<!-- Compteurs dérivés automatiquement -->
<p>{{ store.completedCount() }} / {{ store.totalCount() }} tâches</p>
<!-- Boutons de filtre -->
<div class="btn-group" role="group">
<button (click)="store.setFilter('all')">Tout</button>
<button (click)="store.setFilter('pending')">En cours</button>
<button (click)="store.setFilter('done')">Terminé</button>
</div>
<!-- Liste filtrée — se met à jour automatiquement -->
@for (todo of store.filteredTodos(); track todo.id) {
<div class="todo-item d-flex align-items-center gap-2">
<input type="checkbox"
[checked]="todo.completed"
(change)="store.toggleTodo(todo.id)"
[attr.aria-label]="'Marquer ' + todo.title + ' comme fait'">
<span [class.text-decoration-line-through]="todo.completed">
{{ todo.title }}
</span>
<button (click)="store.removeTodo(todo.id)"
class="btn btn-sm btn-danger ms-auto">
Supprimer
</button>
</div>
}
<!-- Message si liste vide -->
@if (store.isEmpty()) {
<p class="text-muted text-center">Aucune tâche pour le moment.</p>
}
`
})
export class TodoListComponent {
protected readonly store = inject(TodoStore);
}
filteredTodos ne se recalcule pas si vous appelez setFilter('all') deux fois de suite avec la même valeur.
Modifier l'état avec patchState()
patchState() est la seule façon de modifier l'état d'un Signal Store. Elle garantit l'immutabilité en créant toujours un nouvel objet d'état. Elle accepte soit un objet partiel (pour des mises à jour simples), soit une fonction de mise à jour (pour les mises à jour basées sur l'état courant).
Les deux formes de patchState()
// Forme 1 — Objet partiel : pour les mises à jour simples sans dépendance à l'état courant
patchState(store, {
isLoading: true, // Met à jour isLoading à true
selectedId: 42 // Met à jour selectedId à 42
// Toutes les autres propriétés restent inchangées (patch, pas replace)
});
// Forme 2 — Fonction updater : pour les mises à jour qui dépendent de l'état courant
patchState(store, state => ({
// state contient l'état courant en lecture seule
todos: [...state.todos, newTodo], // Ajouter sans muter le tableau
isLoading: !state.isLoading // Basculer en lisant la valeur actuelle
}));
patchState() avec des états imbriqués
// Interface avec état imbriqué
interface UserProfileState {
user: {
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
};
};
isEditing: boolean;
}
// ❌ Mauvais : écraser toutes les préférences avec un seul changement
patchState(store, state => ({
user: {
...state.user,
preferences: { theme: 'dark' } // Perd language et notifications !
}
}));
// ✅ Bon : spread imbriqué pour préserver les propriétés non modifiées
patchState(store, state => ({
user: {
...state.user, // Conserver name, email
preferences: {
...state.user.preferences, // Conserver language et notifications
theme: 'dark' // Modifier seulement le thème
}
}
}));
Intégration avec Immer pour les états complexes
Pour les états très imbriqués, @ngrx/signals s'intègre avec Immer via le plugin withImmerUpdate (ou en utilisant produce directement) pour écrire des mutations apparentes tout en gardant l'immutabilité :
// Installer immer
// npm install immer
import { produce } from 'immer';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
export const CartStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] as CartItem[], total: 0 }),
withMethods(store => ({
// Utiliser produce() d'Immer pour les mises à jour complexes
updateQuantity(productId: number, quantity: number): void {
patchState(store, state =>
// produce() reçoit un draft mutable — plus lisible que le spread imbriqué
produce(state, draft => {
const item = draft.items.find(i => i.productId === productId);
if (item) {
item.quantity = quantity; // Mutation directe sur le draft
// Immer gère l'immutabilité en arrière-plan
}
// Recalculer le total après modification
draft.total = draft.items.reduce(
(sum, i) => sum + i.price * i.quantity, 0
);
})
);
},
// Vider le panier
clearCart(): void {
patchState(store, { items: [], total: 0 });
}
}))
);
patchState() est une mise à jour partielle — elle ne remplace que les propriétés que vous spécifiez. Pour réinitialiser complètement le store à son état initial, passez l'objet initialState entier : patchState(store, initialState).
Plusieurs patchState() en une seule méthode
// patchState accepte plusieurs arguments — appliqués dans l'ordre
withMethods(store => ({
submitForm(formData: FormData): void {
// Étape 1 : passer en mode chargement
patchState(store, { isLoading: true, error: null });
// Les deux patches ci-dessous sont appliqués séquentiellement
// et déclenchent une seule mise à jour de l'UI (batching Angular)
patchState(store,
{ submittedAt: new Date() }, // Patch 1
{ formData: formData } // Patch 2
);
}
}))
Effets et requêtes HTTP dans le Signal Store
NgRx Signal Store gère les effets asynchrones (appels HTTP, timers, WebSocket) directement dans les méthodes via async/await ou en combinant avec RxJS. Pas besoin d'un système d'Effects séparé comme dans NgRx classique.
Méthodes async avec HttpClient
// src/app/stores/product.store.ts
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { firstValueFrom } from 'rxjs'; // Convertir Observable en Promise
interface Product {
id: number;
name: string;
price: number;
stock: number;
}
interface ProductState {
products: Product[];
isLoading: boolean;
error: string | null;
selectedProduct: Product | null;
}
const initialState: ProductState = {
products: [],
isLoading: false,
error: null,
selectedProduct: null
};
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store, http = inject(HttpClient)) => ({
// Charger tous les produits depuis l'API
async loadProducts(): Promise<void> {
// Activer l'indicateur de chargement avant la requête
patchState(store, { isLoading: true, error: null });
try {
// firstValueFrom() convertit l'Observable HttpClient en Promise
const products = await firstValueFrom(
http.get<Product[]>('/api/products')
);
// Mettre à jour l'état avec les données reçues
patchState(store, { products, isLoading: false });
} catch (err: unknown) {
// Capturer et stocker le message d'erreur dans l'état
const message = err instanceof Error ? err.message : 'Erreur inconnue';
patchState(store, { isLoading: false, error: message });
}
},
// Charger un produit spécifique par son ID
async loadProductById(id: number): Promise<void> {
patchState(store, { isLoading: true, error: null });
try {
const product = await firstValueFrom(
http.get<Product>(`/api/products/${id}`)
);
patchState(store, { selectedProduct: product, isLoading: false });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Produit introuvable';
patchState(store, { isLoading: false, error: message });
}
},
// Créer un produit via POST
async createProduct(data: Omit<Product, 'id'>): Promise<void> {
patchState(store, { isLoading: true });
try {
const created = await firstValueFrom(
http.post<Product>('/api/products', data)
);
// Ajouter le produit créé à la liste existante (pas de rechargement complet)
patchState(store, state => ({
products: [...state.products, created],
isLoading: false
}));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Création échouée';
patchState(store, { isLoading: false, error: message });
}
},
// Supprimer un produit via DELETE
async deleteProduct(id: number): Promise<void> {
try {
await firstValueFrom(http.delete(`/api/products/${id}`));
// Supprimer localement sans rechargement API
patchState(store, state => ({
products: state.products.filter(p => p.id !== id)
}));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Suppression échouée';
patchState(store, { error: message });
}
}
}))
);
Utiliser withHooks() pour charger des données à l'initialisation
// withHooks() permet d'exécuter du code au montage/démontage du store
import { signalStore, withState, withMethods, withHooks, patchState } from '@ngrx/signals';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store, http = inject(HttpClient)) => ({
async loadProducts(): Promise<void> { /* ... */ }
})),
withHooks({
// onInit est appelé automatiquement quand le store est injecté pour la première fois
onInit(store) {
// Charger les produits au démarrage sans intervention du composant
store.loadProducts();
console.log('ProductStore initialisé — données chargées');
},
// onDestroy est appelé quand le store est détruit (pour les stores scoped)
onDestroy(store) {
console.log('ProductStore détruit');
}
})
);
Utilisation dans un composant avec gestion d'erreur
// product-list.component.ts
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CurrencyPipe],
template: `
<!-- Affichage conditionnel selon l'état du store -->
@if (store.isLoading()) {
<div class="d-flex justify-content-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
}
<!-- Afficher l'erreur si présente -->
@if (store.error()) {
<div class="alert alert-danger" role="alert">
Erreur : {{ store.error() }}
<button (click)="store.loadProducts()" class="btn btn-sm btn-outline-danger ms-2">
Réessayer
</button>
</div>
}
<!-- Liste des produits -->
@if (!store.isLoading() && !store.error()) {
<div class="row g-3">
@for (product of store.products(); track product.id) {
<div class="col-md-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<p class="card-text">{{ product.price | currency:'EUR' }}</p>
<button (click)="store.deleteProduct(product.id)"
class="btn btn-danger btn-sm">
Supprimer
</button>
</div>
</div>
</div>
}
</div>
}
`
})
export class ProductListComponent {
protected readonly store = inject(ProductStore);
// withHooks onInit a déjà déclenché loadProducts() — aucun appel manuel requis
}
withHooks({ onInit }) pour les stores globaux (root) et appelez manuellement les méthodes de chargement dans ngOnInit() pour les stores scoped aux composants — cela vous donne plus de contrôle sur le cycle de vie.
Architecture scalable
Pour les applications de taille moyenne à grande, il est recommandé de structurer les stores en feature stores indépendants et de les composer si nécessaire. NgRx Signal Store offre plusieurs patterns avancés pour cela.
Feature stores — un store par domaine métier
// Pattern recommandé : un store par feature/domaine
// src/app/features/auth/auth.store.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
import { firstValueFrom } from 'rxjs';
interface AuthState {
user: { id: number; email: string; role: string } | null;
token: string | null;
isLoading: boolean;
error: string | null;
}
const initialAuthState: AuthState = {
user: null,
token: null,
isLoading: false,
error: null
};
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState(initialAuthState),
withComputed(store => ({
// Signal dérivé : l'utilisateur est-il connecté ?
isAuthenticated: computed(() => store.token() !== null),
// Signal dérivé : rôle de l'utilisateur (ou 'guest' si non connecté)
userRole: computed(() => store.user()?.role ?? 'guest'),
// Signal dérivé : email affiché dans la navbar
displayName: computed(() => store.user()?.email ?? 'Invité')
})),
withMethods((store, http = inject(HttpClient), router = inject(Router)) => ({
// Connexion — POST /api/auth/login
async login(email: string, password: string): Promise<void> {
patchState(store, { isLoading: true, error: null });
try {
const response = await firstValueFrom(
http.post<{ token: string; user: AuthState['user'] }>(
'/api/auth/login',
{ email, password }
)
);
// Stocker le token en localStorage pour persistance
localStorage.setItem('auth_token', response.token!);
patchState(store, {
token: response.token,
user: response.user,
isLoading: false
});
// Rediriger vers le tableau de bord après connexion
router.navigate(['/dashboard']);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Identifiants incorrects';
patchState(store, { isLoading: false, error: message });
}
},
// Déconnexion — nettoyer l'état et rediriger
logout(): void {
localStorage.removeItem('auth_token');
patchState(store, initialAuthState); // Réinitialiser tout l'état auth
router.navigate(['/login']);
},
// Restaurer la session depuis localStorage au démarrage
restoreSession(): void {
const token = localStorage.getItem('auth_token');
if (token) {
// Token présent : mettre à jour l'état sans appel API
patchState(store, { token });
// Puis valider le token via l'API (optionnel)
}
}
}))
);
Créer des stores personnalisables avec des fonctions factory
// Pattern factory : générer un store configuré dynamiquement
function createPaginatedStore<T>(apiUrl: string) {
// Retourne un signalStore typé générique
return signalStore(
withState({
items: [] as T[],
currentPage: 1,
pageSize: 10,
totalItems: 0,
isLoading: false,
error: null as string | null
}),
withComputed(store => ({
// Calculer le nombre total de pages
totalPages: computed(() =>
Math.ceil(store.totalItems() / store.pageSize())
),
// Détecter si on est à la première ou dernière page
isFirstPage: computed(() => store.currentPage() === 1),
isLastPage: computed(() =>
store.currentPage() >= Math.ceil(store.totalItems() / store.pageSize())
)
})),
withMethods((store, http = inject(HttpClient)) => ({
async loadPage(page: number): Promise<void> {
patchState(store, { isLoading: true, currentPage: page });
try {
const response = await firstValueFrom(
http.get<{ items: T[]; total: number }>(
`${apiUrl}?page=${page}&size=${store.pageSize()}`
)
);
patchState(store, {
items: response.items,
totalItems: response.total,
isLoading: false
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur API';
patchState(store, { isLoading: false, error: msg });
}
},
nextPage(): void {
if (store.currentPage() < Math.ceil(store.totalItems() / store.pageSize())) {
this.loadPage(store.currentPage() + 1);
}
},
prevPage(): void {
if (store.currentPage() > 1) {
this.loadPage(store.currentPage() - 1);
}
}
}))
);
}
// Utilisation : créer des stores paginés typés pour chaque entité
export const ProductListStore = createPaginatedStore<Product>('/api/products');
export const OrderListStore = createPaginatedStore<Order>('/api/orders');
export const UserListStore = createPaginatedStore<User>('/api/users');
Bonnes pratiques d'architecture
- Un store par domaine métier — éviter les stores monolithiques
- Garder les stores sans référence circulaire — si le store A dépend du store B, injecter B dans les méthodes de A avec
inject() - Typer l'état initial explicitement — créer une interface TypeScript pour chaque state
- Ne jamais exposer
patchState()en dehors du store — encapsuler toutes les mutations dans des méthodes nommées - Utiliser
withComputed()pour toute logique de dérivation — jamais dans le template directement
- Chaque store a une interface TypeScript pour son état
- L'état initial est exporté comme constante séparée
- Les méthodes async gèrent les erreurs avec try/catch
- Les états dérivés sont dans
withComputed() - Le champ
errorest réinitialisé avant chaque requête - Les stores globaux ont
{ providedIn: 'root' } - Les stores scoped sont dans
providersdu composant - Aucune mutation directe de l'état sans
patchState()
Conclusion
NgRx Signal Store représente une évolution majeure dans la gestion d'état Angular. Il conserve les principes fondamentaux de NgRx (état immutable, flux unidirectionnel, encapsulation) tout en éliminant le boilerplate Actions/Reducers/Effects et en adoptant nativement les Signals Angular. Le résultat est un code plus concis, entièrement typé, et parfaitement aligné avec la direction prise par Angular depuis la version 17.
Pour les nouvelles applications Angular 19+, Signal Store est le choix recommandé pour toute gestion d'état qui dépasse le scope local d'un composant. Il est particulièrement efficace pour les feature stores (panier, authentification, données paginées) et les applications qui n'ont pas encore adopté le NgRx classique. Pour les applications enterprise existantes avec NgRx Store, la migration peut se faire progressivement, feature par feature.
signalStore() + withState() + withMethods(). Ajoutez withComputed() dès que vous avez besoin de valeurs dérivées, et withHooks() pour les chargements automatiques. La courbe d'apprentissage est courte — en moins d'une journée, vous pouvez migrer un composant entier vers Signal Store.