Appearance
Utilisation efficace de NgRx Signal Store
Dans ce tutoriel, nous allons explorer les meilleures pratiques et les modèles de conception pour utiliser NgRx Signal Store de manière efficace dans vos applications Angular. Vous découvrirez comment structurer vos stores, quand les utiliser, et comment les intégrer harmonieusement dans l'architecture de votre application.
Dans la vie quotidienne
Pour comprendre l'utilisation optimale de NgRx Signal Store, pensez à l'organisation d'une bibliothèque. Dans une bibliothèque bien gérée, les livres sont classés par catégories (comme vos différents stores), les sections sont clairement identifiées (comme les différentes parties de votre état), et il existe un système de catalogage (équivalent à vos selectors) qui vous permet de trouver rapidement ce que vous cherchez. De plus, il y a des procédures pour emprunter ou retourner des livres (comme vos méthodes de mise à jour). Cette organisation permet à la bibliothèque de fonctionner efficacement, tout comme une bonne structure de stores permet à votre application de gérer l'état de manière optimale.
Quand utiliser NgRx Signal Store
NgRx Signal Store est particulièrement adapté pour :
- États partagés : Lorsque plusieurs composants ont besoin d'accéder et de modifier le même état.
- États complexes : Quand l'état de votre application devient trop complexe pour être géré directement dans les composants.
- Logique métier centralisée : Pour centraliser la logique de votre application indépendamment des composants.
- Opérations asynchrones : Pour gérer proprement les requêtes HTTP, les WebSockets, ou d'autres opérations asynchrones.
- États persistants : Lorsque vous devez persister et restaurer l'état (session utilisateur, préférences, etc.).
En revanche, vous pourriez ne pas avoir besoin de Signal Store pour :
- Formulaires simples : Pour des formulaires basiques,
FormGroup
etFormControl
peuvent suffire. - États locaux éphémères : Utilisez simplement
signal()
dans le composant pour des états temporaires qui ne concernent qu'un seul composant. - Applications très petites : Pour des applications minuscules, la surcharge d'architecture pourrait ne pas être justifiée.
Architecture recommandée
Structuration des stores
Pour les applications de taille moyenne à grande, nous recommandons la structure suivante :
src/
app/
shared/
models/ # Interfaces et types partagés
utils/ # Fonctions utilitaires
core/
stores/ # Stores globaux
auth/
auth.interface.ts
auth.store.ts
theme/
theme.interface.ts
theme.store.ts
features/
products/
stores/ # Stores spécifiques aux fonctionnalités
product.interface.ts
product.store.ts
components/
services/
cart/
stores/
cart.interface.ts
cart.store.ts
components/
services/
Niveaux de stores
Nous recommandons de distinguer trois niveaux de stores :
Global Stores (niveau application) : Injectés avec
{ providedIn: 'root' }
, ils gèrent les états partagés par toute l'application (authentification, thème, etc.).Feature Stores (niveau fonctionnalité) : Configurés au niveau d'un module de fonctionnalité ou d'un composant parent, ils gèrent l'état spécifique à une fonctionnalité.
Component Stores (niveau composant) : Fournis directement dans un composant, ils gèrent l'état local d'un composant ou d'un petit groupe de composants.
Bonnes pratiques
1. Interfaces et typage fort
Définissez toujours des interfaces claires pour vos états :
ts
// user.interface.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface UserState {
users: User[];
selectedUserId: number | null;
isLoading: boolean;
error: string | null;
}
2. Séparation des préoccupations
Séparez la définition de l'état, la lecture et l'écriture :
ts
// user.store.ts
export const UserStore = signalStore(
// État initial
withState<UserState>({ /*...*/ }),
// Lecture (computed signals)
withComputed(/*...*/),
// Écriture (méthodes)
withMethods(/*...*/),
// Effets secondaires
withEffects(/*...*/)
);
3. Actions atomiques et significatives
Créez des méthodes qui représentent des actions significatives du domaine :
ts
// MAUVAIS
updateUser(userId: number, data: any) {
// Code générique sans validation
}
// BON
promoteToAdmin(userId: number) {
const user = this.getUserById(userId);
if (!user) throw new Error('User not found');
patchState(store, (state) => ({
users: state.users.map(u =>
u.id === userId ? { ...u, role: 'admin' } : u
)
}));
// Événements de journalisation ou autres effets secondaires
this.logUserAction(userId, 'promoted to admin');
}
4. Immutabilité
Respectez toujours l'immutabilité lors des mises à jour de l'état :
ts
// MAUVAIS: Mutation directe
addItem(item) {
// ❌ Ne fonctionne pas, mutation directe
store.items.push(item);
}
// BON: Update immutable
addItem(item) {
patchState(store, (state) => ({
items: [...state.items, item]
}));
}
5. Erreurs et chargement
Gérez systématiquement les états de chargement et d'erreur :
ts
fetchUsers: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
http.get<User[]>('/api/users').pipe(
tap(users => {
patchState(store, { users, isLoading: false });
}),
catchError(error => {
patchState(store, {
isLoading: false,
error: 'Impossible de charger les utilisateurs'
});
return EMPTY;
})
)
)
)
)
6. Optimisations de performance
Évitez les recalculs inutiles en utilisant judicieusement les signaux calculés :
ts
// Potentiellement inefficace si appelé souvent
getNonAdminUsers() {
return store.users().filter(user => user.role !== 'admin');
}
// Optimisé avec computed
withComputed((store) => ({
nonAdminUsers: computed(() =>
store.users().filter(user => user.role !== 'admin')
)
}))
7. Composition de stores
Pour les applications complexes, décomposez et composez vos stores :
ts
// Créer des features réutilisables
function withPagination<T extends object>() {
return signalStoreFeature(
withState({
page: 1,
pageSize: 20,
totalItems: 0
}),
withMethods((store) => ({
nextPage() {
patchState(store, { page: store.page() + 1 });
},
prevPage() {
if (store.page() > 1) {
patchState(store, { page: store.page() - 1 });
}
},
goToPage(page: number) {
patchState(store, { page });
}
}))
);
}
// Utiliser la feature dans différents stores
export const ProductStore = signalStore(
withState<ProductState>({ products: [] }),
withPagination(), // Ajoute les fonctionnalités de pagination
// ...autres features
);
Modèles courants
Communication entre composants
NgRx Signal Store simplifie la communication entre composants :
ts
// Dans un composant parent ou enfant
const userStore = inject(UserStore);
// Réagir aux changements
effect(() => {
const selectedUser = userStore.selectedUser();
if (selectedUser) {
// Faire quelque chose quand l'utilisateur sélectionné change
}
});
// Mettre à jour depuis n'importe quel composant
userStore.selectUser(123);
Gestion des formulaires
Synchroniser les formulaires avec votre état :
ts
import { Component, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
template: `
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<input formControlName="name" />
<input formControlName="email" />
<button type="submit">Sauvegarder</button>
</form>
`
})
export class UserEditComponent implements OnInit {
userStore = inject(UserStore);
fb = inject(FormBuilder);
userForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
});
ngOnInit() {
// Remplir le formulaire avec les données actuelles
const user = this.userStore.selectedUser();
if (user) {
this.userForm.patchValue({
name: user.name,
email: user.email
});
}
}
onSubmit() {
if (this.userForm.invalid) return;
const userId = this.userStore.selectedUserId();
if (userId) {
this.userStore.updateUser(userId, this.userForm.value);
}
}
}
Lazy loading et stores par fonctionnalité
Pour les applications de grande taille avec lazy loading :
ts
// Dans un module de fonctionnalité
@NgModule({
imports: [...],
providers: [
// Fournir le store au niveau du module
ProductStore
]
})
export class ProductModule { }
Dans un composant standalone :
ts
@Component({
standalone: true,
imports: [...],
providers: [ProductStore], // Portée limitée au composant et ses enfants
template: `...`
})
export class ProductsComponent { }
Sélecteurs avec paramètres
Les sélecteurs paramétrés peuvent être implémentés comme des méthodes retournant des signaux calculés :
ts
// Dans le store
withMethods((store) => ({
getProductsByCategory(category: string) {
return computed(() =>
store.products().filter(p => p.category === category)
);
}
}))
// Utilisation
@Component({
template: `
<div>
<h2>Électronique</h2>
<product-list [products]="electronicsProducts()"></product-list>
<h2>Vêtements</h2>
<product-list [products]="clothingProducts()"></product-list>
</div>
`
})
export class CategoryComponent {
productStore = inject(ProductStore);
electronicsProducts = this.productStore.getProductsByCategory('electronics');
clothingProducts = this.productStore.getProductsByCategory('clothing');
}
PERFORMANCES
Les sélecteurs avec paramètres créent de nouveaux signals calculés à chaque appel. Utilisez-les judicieusement et envisagez de les mettre en cache si nécessaire.
Gestion des mises à jour optimistes
Pour une meilleure expérience utilisateur, implémentez des mises à jour optimistes :
ts
updateUser: rxMethod<{ userId: number, user: Partial<User> }>(
pipe(
tap(({ userId, user }) => {
// 1. Sauvegarde de l'état actuel
const currentUsers = store.users();
const oldUser = currentUsers.find(u => u.id === userId);
// 2. Mise à jour optimiste
patchState(store, (state) => ({
users: state.users.map(u =>
u.id === userId ? { ...u, ...user } : u
)
}));
// 3. Requête API
return { userId, user, oldUser, currentUsers };
}),
switchMap(({ userId, user, oldUser, currentUsers }) =>
http.patch<User>(`/api/users/${userId}`, user).pipe(
// 4. En cas de succès, utiliser la réponse du serveur
tap(updatedUser => {
patchState(store, (state) => ({
users: state.users.map(u =>
u.id === userId ? updatedUser : u
)
}));
}),
// 5. En cas d'erreur, restaurer l'état précédent
catchError(error => {
console.error('Update failed', error);
// Restaurer l'état précédent
patchState(store, { users: currentUsers });
// Afficher une notification d'erreur
notificationService.error('La mise à jour a échoué');
return EMPTY;
})
)
)
)
)
Stratégies de débogage
Pour faciliter le débogage de vos stores :
- Journalisation des changements
ts
import { signalStore, withState, withHooks } from '@ngrx/signals';
import { effect } from '@angular/core';
export const DebugStore = signalStore(
withState({ /*...*/ }),
withHooks({
onInit(store) {
if (environment.development) {
effect(() => {
// Log chaque changement d'état
console.log('State updated:', {
property1: store.property1(),
property2: store.property2(),
// ...
});
});
}
}
})
);
- Redux DevTools Integration
Si vous utilisez beaucoup de Signal Stores, vous pouvez créer un wrapper pour les intégrer avec Redux DevTools :
ts
// Utilitaire simpliste pour l'exemple
function withDevTools<T>(name: string, store: T): T {
if (!environment.production && (window as any).__REDUX_DEVTOOLS_EXTENSION__) {
const devTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect({
name: name,
features: { jump: true }
});
// Créer un effet qui envoie chaque changement à DevTools
effect(() => {
const state = {};
// Collecter toutes les propriétés du store
for (const key of Object.keys(store)) {
if (typeof store[key] === 'function' && key !== 'constructor') {
try {
const value = store[key]();
state[key] = value;
} catch (e) {
// Ignorer les méthodes qui ne sont pas des signaux
}
}
}
devTools.send('state_updated', state);
});
}
return store;
}
// Utilisation
export const debuggableStore = withDevTools('UserStore', inject(UserStore));