Appearance
Écriture de l'état dans NgRx Signal Store
L'écriture de l'état est une opération fondamentale dans toute application utilisant une gestion d'état. NgRx Signal Store propose une approche simple et intuitive pour modifier l'état, tout en garantissant l'immutabilité et la prévisibilité des changements.
L'écriture d'état dans la vie quotidienne
Pour comprendre l'écriture d'état, pensez à la mise à jour d'un agenda. Lorsque vous planifiez un nouveau rendez-vous, vous n'arrachez pas la page de votre agenda pour en créer une nouvelle - vous ajoutez simplement une entrée à l'emplacement approprié. De même, lorsque vous modifiez l'état dans un Signal Store, vous ne recréez pas l'intégralité de l'état mais vous spécifiez précisément quelles parties doivent être mises à jour.
withMethods et patchState
Dans NgRx Signal Store, les méthodes de modification de l'état sont définies à l'aide de withMethods
. À l'intérieur de ces méthodes, on utilise généralement patchState
pour effectuer les modifications :
ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
interface CounterState {
count: number;
lastUpdated: Date | null;
}
export const CounterStore = signalStore(
withState<CounterState>({
count: 0,
lastUpdated: null
}),
withMethods((store) => ({
// Méthode pour incrémenter le compteur
increment() {
patchState(store, (state) => ({
count: state.count + 1,
lastUpdated: new Date()
}));
},
// Méthode pour décrémenter le compteur
decrement() {
patchState(store, (state) => ({
count: state.count - 1,
lastUpdated: new Date()
}));
},
// Méthode pour définir une valeur spécifique
setValue(newValue: number) {
patchState(store, {
count: newValue,
lastUpdated: new Date()
});
},
// Méthode pour réinitialiser le compteur
reset() {
patchState(store, {
count: 0,
lastUpdated: new Date()
});
}
}))
);
IMMUTABILITÉ
patchState
préserve l'immutabilité de l'état. Il crée une nouvelle version de l'état en fusionnant les changements avec l'état actuel, plutôt que de modifier l'état existant.
Deux façons d'utiliser patchState
Il existe deux façons principales d'utiliser patchState
:
- Avec un objet partiel : Pour définir directement les nouvelles valeurs
ts
// Mise à jour directe
patchState(store, {
count: 10,
lastUpdated: new Date()
});
- Avec une fonction : Pour utiliser l'état actuel dans le calcul des nouvelles valeurs
ts
// Mise à jour basée sur l'état actuel
patchState(store, (state) => ({
count: state.count + 1,
lastUpdated: new Date()
}));
Mise à jour d'objets imbriqués
Pour mettre à jour des propriétés imbriquées dans un objet, vous devez respecter l'immutabilité :
ts
interface UserState {
user: {
profile: {
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
}
}
}
}
// Dans une méthode du store
updateTheme(theme: 'light' | 'dark') {
patchState(store, (state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
preferences: {
...state.user.profile.preferences,
theme
}
}
}
}));
}
OBJETS IMBRIQUÉS
Les mises à jour d'objets imbriqués peuvent devenir vertigineuses. Dans ce cas, envisagez de restructurer votre état ou d'utiliser des bibliothèques comme Immer qui simplifient ces opérations.
Mise à jour des tableaux
Lors de la mise à jour de tableaux, vous devez également préserver l'immutabilité :
ts
interface TaskState {
tasks: { id: number; text: string; completed: boolean }[];
}
// Ajouter une tâche
addTask(text: string) {
const newTask = { id: Date.now(), text, completed: false };
patchState(store, (state) => ({
tasks: [...state.tasks, newTask]
}));
}
// Supprimer une tâche
removeTask(id: number) {
patchState(store, (state) => ({
tasks: state.tasks.filter(task => task.id !== id)
}));
}
// Mettre à jour une tâche
updateTask(id: number, updates: Partial<Task>) {
patchState(store, (state) => ({
tasks: state.tasks.map(task =>
task.id === id
? { ...task, ...updates }
: task
)
}));
}
// Basculer l'état completed d'une tâche
toggleTask(id: number) {
patchState(store, (state) => ({
tasks: state.tasks.map(task =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
}));
}
Gestion des effets secondaires
Les méthodes de store peuvent également gérer des effets secondaires, comme sauvegarder l'état dans le localStorage :
ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
export const PreferencesStore = signalStore(
withState({
theme: 'light',
fontSize: 'medium',
language: 'fr'
}),
withMethods((store) => ({
// Méthode pour mettre à jour le thème et le sauvegarder
setTheme(theme: 'light' | 'dark') {
// Mettre à jour l'état
patchState(store, { theme });
// Effet secondaire : sauvegarder dans localStorage
localStorage.setItem('theme', theme);
},
// Méthode pour mettre à jour toutes les préférences
updatePreferences(prefs: Partial<typeof store>) {
// Mettre à jour l'état
patchState(store, prefs);
// Effet secondaire : sauvegarder dans localStorage
localStorage.setItem('preferences', JSON.stringify({
theme: store.theme(),
fontSize: store.fontSize(),
language: store.language(),
...prefs
}));
}
}))
);
SÉPARATION DES PRÉOCCUPATIONS
Pour des applications plus complexes, il est préférable de séparer les effets secondaires des méthodes de mise à jour de l'état en utilisant withEffects
ou rxMethod
, ce qui sera abordé dans un autre tutoriel.
Méthodes prenant des paramètres
Les méthodes peuvent prendre des paramètres pour rendre les modifications d'état plus flexibles :
ts
interface CartState {
items: { id: number; name: string; price: number; quantity: number }[];
total: number;
}
export const CartStore = signalStore(
withState<CartState>({
items: [],
total: 0
}),
withMethods((store) => ({
/**
* Ajoute un produit au panier ou augmente sa quantité s'il existe déjà
* @param product Le produit à ajouter au panier
*/
addToCart(product: { id: number; name: string; price: number }) {
patchState(store, (state) => {
const existingItem = state.items.find(item => item.id === product.id);
if (existingItem) {
// Produit déjà dans le panier, augmenter la quantité
const updatedItems = state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
return {
items: updatedItems,
total: calculateTotal(updatedItems)
};
} else {
// Nouveau produit, l'ajouter au panier
const newItem = { ...product, quantity: 1 };
const updatedItems = [...state.items, newItem];
return {
items: updatedItems,
total: calculateTotal(updatedItems)
};
}
});
},
/**
* Met à jour la quantité d'un produit dans le panier
* @param productId ID du produit à mettre à jour
* @param quantity Nouvelle quantité
*/
updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
this.removeFromCart(productId);
return;
}
patchState(store, (state) => {
const updatedItems = state.items.map(item =>
item.id === productId
? { ...item, quantity }
: item
);
return {
items: updatedItems,
total: calculateTotal(updatedItems)
};
});
},
/**
* Supprime un produit du panier
* @param productId ID du produit à supprimer
*/
removeFromCart(productId: number) {
patchState(store, (state) => {
const updatedItems = state.items.filter(item => item.id !== productId);
return {
items: updatedItems,
total: calculateTotal(updatedItems)
};
});
},
/**
* Vide entièrement le panier
*/
clearCart() {
patchState(store, {
items: [],
total: 0
});
}
}))
);
// Fonction utilitaire pour calculer le total
function calculateTotal(items: { price: number; quantity: number }[]) {
return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
Bonnes pratiques pour l'écriture d'état
Granularité appropriée : Créez des méthodes qui représentent des actions significatives du domaine.
Vérifications de validité : Incluez des validations pour éviter les états invalides.
ts
updateQuantity(productId: number, quantity: number) {
// Valider les entrées
if (quantity <= 0) {
throw new Error('La quantité doit être supérieure à 0');
}
if (!Number.isInteger(quantity)) {
throw new Error('La quantité doit être un nombre entier');
}
// Vérifier que le produit existe
const productExists = store.items().some(item => item.id === productId);
if (!productExists) {
throw new Error(`Produit avec ID ${productId} non trouvé dans le panier`);
}
// Si tout est valide, mettre à jour l'état
patchState(store, (state) => ({ /*...*/ }));
}
- Transactions atomiques : Si une opération nécessite plusieurs mises à jour, effectuez-les dans une seule opération
patchState
pour éviter des états intermédiaires indésirables.
Exemple complet
Voici un exemple complet d'écriture d'état pour un gestionnaire de tâches :