Appearance
Les Effets dans NgRx Signal Store
Les effets permettent de gérer les opérations asynchrones et les effets secondaires dans votre application Angular avec NgRx Signal Store. Ils sont essentiels pour interagir avec des API externes, effectuer des opérations asynchrones, ou déclencher plusieurs actions en cascade.
Les effets secondaires dans la vie quotidienne
Pour comprendre les effets, pensez à un système d'arrosage automatique dans un jardin. L'arrosage lui-même est un effet secondaire déclenché par une condition (comme un horaire programmé ou un capteur d'humidité). De même, dans une application, les effets secondaires sont des opérations qui se produisent en réponse à un changement d'état ou à une action utilisateur, comme l'envoi d'une requête HTTP, le stockage de données en local, ou la navigation entre les pages.
Gestion des effets dans NgRx Signal Store
Dans NgRx Signal Store, vous pouvez gérer les effets de deux manières principales :
- withHooks : Pour définir des effets qui s'exécutent à des moments spécifiques du cycle de vie du store
- rxMethod : Pour des effets basés sur les Observables RxJS (plus flexibles)
Effets avec withHooks
Pour les effets liés au cycle de vie du signal store, vous pouvez utiliser withHooks
:
ts
import { signalStore, withState, withHooks, patchState } from '@ngrx/signals';
import { inject, effect } from '@angular/core';
import { Router } from '@angular/router';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
}
export const AuthStore = signalStore(
withState<AuthState>({
user: null,
isAuthenticated: false
}),
withHooks({
/**
* Méthode qui s'exécute lors de l'initialisation du store
* Vérifie si un token est présent et met à jour l'état en conséquence
*/
onInit(store) {
// Injecter les dépendances nécessaires
const router = inject(Router);
// Effet exécuté chaque fois que isAuthenticated change
effect(() => {
// Récupérer la valeur actuelle de isAuthenticated
const isAuthenticated = store.isAuthenticated();
// Naviguer en fonction de l'état d'authentification
if (isAuthenticated) {
router.navigate(['/dashboard']);
} else {
router.navigate(['/login']);
}
});
// Vérifier s'il y a un token dans localStorage
const token = localStorage.getItem('auth_token');
if (token) {
// Valider le token et mettre à jour l'état
validateToken(token).then(user => {
if (user) {
patchState(store, {
user,
isAuthenticated: true
});
} else {
localStorage.removeItem('auth_token');
}
});
}
},
/**
* Méthode qui s'exécute à la destruction du store
* Nettoyage des ressources si nécessaire
*/
onDestroy() {
console.log('Store destroyed, cleaning up resources');
// Nettoyage des ressources si nécessaire
}
}),
withMethods((store) => {
const router = inject(Router);
return {
/**
* Méthode pour déconnecter l'utilisateur avec effet secondaire de redirection
*/
logout() {
// Effacer le token
localStorage.removeItem('auth_token');
// Mettre à jour l'état
patchState(store, {
user: null,
isAuthenticated: false
});
// Effet secondaire: redirection
router.navigate(['/login']);
}
};
})
);
// Fonction auxiliaire pour valider un token
async function validateToken(token: string): Promise<User | null> {
// Implémentation réelle qui validerait le token avec une API
try {
const response = await fetch('/api/validate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Token validation failed', error);
return null;
}
}
HOOK ONINIT
La méthode onInit
est automatiquement appelée lorsque le store est initialisé, ce qui est parfait pour charger des données initiales ou mettre en place des effets.
Effets réactifs avec rxMethod
Pour des effets plus complexes impliquant des flux de données, des annulations, ou des opérateurs RxJS avancés, utilisez rxMethod
:
ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, catchError, debounceTime, filter, of, EMPTY } from 'rxjs';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface SearchState {
query: string;
results: any[];
isLoading: boolean;
error: string | null;
}
export const SearchStore = signalStore(
withState<SearchState>({
query: '',
results: [],
isLoading: false,
error: null
}),
withMethods((store) => {
// Injecter HttpClient
const http = inject(HttpClient);
return {
// Méthode synchrone pour mettre à jour la requête
setQuery(query: string) {
patchState(store, { query });
},
// Méthode réactive pour effectuer la recherche
search: rxMethod<string>(
pipe(
// Ignorer les requêtes vides
filter(query => query.trim().length > 2),
// Attendre que l'utilisateur arrête de taper
debounceTime(300),
// Indiquer que le chargement est en cours
tap(() => patchState(store, { isLoading: true, error: null })),
// Effectuer la requête HTTP
switchMap(query =>
http.get<any[]>(`/api/search?q=${encodeURIComponent(query)}`).pipe(
// Gérer le succès
tap(results => {
patchState(store, {
results,
isLoading: false
});
}),
// Gérer les erreurs
catchError(error => {
console.error('Search failed', error);
patchState(store, {
results: [],
isLoading: false,
error: 'La recherche a échoué. Veuillez réessayer.'
});
return EMPTY; // Ne pas propager l'erreur
})
)
)
)
)
};
})
);
RXJS CONNAISSANCE REQUISE
L'utilisation de rxMethod
nécessite une connaissance de base de RxJS et de ses opérateurs. Si vous n'êtes pas familier avec RxJS, il peut être préférable de commencer par des effets simples avec withHooks
.
Gestion des états asynchrones
Pour gérer efficacement les états de chargement, d'erreur et de succès des opérations asynchrones, il faut inclure ces états dans votre modèle et les mettre à jour explicitement :
ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap, catchError, EMPTY } from 'rxjs';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
interface TodoState {
todos: Todo[];
isLoading: boolean;
error: string | null;
}
export const TodoStore = signalStore(
withState<TodoState>({
todos: [],
isLoading: false,
error: null
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
/**
* Méthode pour charger les todos depuis l'API
* Gère manuellement les états de chargement, d'erreur et de succès
*/
loadTodos: rxMethod<void>(
pipe(
// Mettre à jour l'état pour indiquer le chargement
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
http.get<Todo[]>('/api/todos').pipe(
// Gérer le succès
tap(todos => {
patchState(store, {
todos,
isLoading: false
});
}),
// Gérer les erreurs
catchError(error => {
console.error('Failed to load todos', error);
patchState(store, {
isLoading: false,
error: 'Impossible de charger les tâches. Veuillez réessayer.'
});
return EMPTY;
})
)
)
)
)
};
})
);
Cette approche manuelle vous donne un contrôle total sur la façon dont vous gérez les différents états de vos opérations asynchrones.
STRUCTURE D'ÉTAT COHÉRENTE
Adoptez une structure cohérente pour vos états asynchrones dans toute votre application. Par exemple, utilisez toujours isLoading
, error
et si nécessaire lastUpdated
pour chaque entité que vous chargez depuis une API.
Déclencher des effets
Les effets peuvent être déclenchés de plusieurs façons :
- Automatiquement via des effets réactifs qui surveillent l'état
- Manuellement en appelant une méthode du store
- Au démarrage avec le hook
onInit
Exemple d'utilisation dans un composant
ts
import { Component, inject, OnInit } from '@angular/core';
import { SearchStore } from './search.store';
@Component({
selector: 'app-search',
template: `
<div>
<input
[ngModel]="searchStore.query()"
(ngModelChange)="updateQuery($event)"
placeholder="Rechercher..."
/>
@if (searchStore.isLoading()) {
<div class="spinner">Chargement...</div>
}
@if (searchStore.error()) {
<div class="error">{{ searchStore.error() }}</div>
}
<ul class="results">
@for (result of searchStore.results(); track result.id) {
<li>{{ result.title }}</li>
}
</ul>
</div>
`
})
export class SearchComponent implements OnInit {
searchStore = inject(SearchStore);
ngOnInit() {
// Exemple: initialiser avec une recherche par défaut
this.updateQuery('initial query');
}
updateQuery(query: string) {
// Met à jour la requête dans le store
this.searchStore.setQuery(query);
// Déclenche la recherche
this.searchStore.search(query);
}
}
Annulation d'effets
Un avantage majeur de l'utilisation de rxMethod
est la gestion automatique des annulations. Par exemple, avec switchMap
, si une nouvelle recherche est lancée avant la fin de la précédente, la requête précédente est automatiquement annulée.
Pour une gestion plus explicite des annulations, vous pouvez utiliser takeUntilDestroyed
:
ts
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
// Dans un store ou un service
const longOperation = rxMethod<void>(
pipe(
switchMap(() => http.get('/api/long-operation').pipe(
takeUntilDestroyed(), // Annule automatiquement si le composant est détruit
// ...
))
)
);
Effets combinés et chaînés
Vous pouvez combiner plusieurs effets ou les chaîner pour des workflows complexes :
ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap, mergeMap, map, catchError, of } from 'rxjs';
export const OrderStore = signalStore(
withState({
cart: [],
order: null,
paymentStatus: null,
isProcessing: false,
error: null
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
// Effet qui déclenche une séquence d'opérations
checkout: rxMethod<{ userId: number }>(
pipe(
tap(() => patchState(store, { isProcessing: true, error: null })),
// 1. Créer une commande
switchMap(({ userId }) =>
http.post('/api/orders', { userId, items: store.cart() }).pipe(
// 2. Traiter le paiement pour cette commande
mergeMap(order => {
patchState(store, { order });
return http.post(`/api/payments`, { orderId: order.id });
}),
// 3. Enregistrer le résultat et nettoyer
map(paymentResult => {
patchState(store, {
paymentStatus: paymentResult.status,
isProcessing: false,
// Vider le panier si le paiement est réussi
cart: paymentResult.status === 'completed' ? [] : store.cart()
});
return paymentResult;
}),
// Gérer les erreurs à n'importe quelle étape
catchError(error => {
patchState(store, {
isProcessing: false,
error: 'La commande a échoué: ' + error.message
});
return of({ status: 'failed', error });
})
)
)
)
)
};
})
);
Injection de dépendances dans les effets
Vous pouvez facilement injecter des services Angular dans vos effets grâce à la fonction inject
:
ts
withMethods((store) => {
// Injecter des services
const http = inject(HttpClient);
const router = inject(Router);
const notificationService = inject(NotificationService);
return {
// Utiliser les services injectés dans les effets
saveUserProfile: rxMethod<UserProfile>(
pipe(
switchMap(profile =>
http.put<UserProfile>(`/api/users/${profile.id}`, profile).pipe(
tap(updatedProfile => {
// Mettre à jour l'état
patchState(store, { profile: updatedProfile });
// Effets secondaires multiples
notificationService.success('Profil mis à jour avec succès');
router.navigate(['/profile', updatedProfile.id]);
})
)
)
)
)
};
});
Bonnes pratiques pour les effets
Séparation des préoccupations : Utilisez les effets pour isoler les opérations impures (HTTP, localStorage, etc.) du reste de votre logique métier.
Gestion des erreurs : Assurez-vous de toujours gérer les erreurs dans vos effets pour éviter que les erreurs non interceptées ne se propagent.
Testabilité : Structurez vos effets pour qu'ils soient facilement testables, en injectant les dépendances plutôt qu'en les instanciant directement.
Choix du bon opérateur :
switchMap
: Pour annuler les requêtes précédentes (idéal pour les recherches)mergeMap
: Pour exécuter plusieurs requêtes en parallèleconcatMap
: Pour exécuter des requêtes dans l'ordre, l'une après l'autre
Exemple complet
Voici un exemple complet d'une gestion d'authentification avec effets :