Jusqu’à maintenant, NgRx Store est une des solutions principales permettant de gérer l'état global d’une application Angular de manière centralisée et partagée entre les composants.
Comme présenté dans le précédent article du blog Ippon Angular et les Signals, l’arrivée des Signals (à partir d’Angular 17), simplifie grandement la gestion de la réactivité de l’application en limitant l’usage de flux asynchrones (Observables). De plus, les Signals permettent de gagner en performance avec la suppression de zone.js et en rafraîchissant uniquement les valeurs qui ont changées.
Nous allons voir dans cet article comment NgRx Signal Store innove en combinant les Signals et la gestion du store, rendant le concept intuitif, simple d’utilisation, et surtout avec beaucoup moins de code à produire. Aussi, comment cette librairie peut être étendue avec les Custom Store Features.
Rappels sur NgRx Store
NgRx Store est une bibliothèque Angular de gestion d’état, inspirée de Redux dans l’écosystème React. Son utilisation demande à maîtriser, les notions de :
- Store : qui centralise et maintient l'état courant de l’application.
- Actions : qui correspondent à des événements liés à l’action de l’utilisateur sur l’application.
- Reducers : qui permettent de changer l’état en fonction d’une Action.
- Selectors : qui permettent au composant d’extraire des données du Store sans en connaître l’état global.
- Effects : qui permettent d'appeler des services externes en fonction de la réception d’une Action et de retourner de nouvelles actions.
Même si cette architecture de NgRx Store est claire et bien définie, toutes ces notions génèrent beaucoup de code, sont coûteuses en termes d’apprentissage et de mise en place.
Nous allons voir comment cela est grandement amélioré avec NgRx Signal Store.
NgRx Signal Store
La simplification avec NgRx Signal Store
NgRx Signal Store est une extension de NgRx qui revoit complètement la gestion du store en se basant sur les Signals d’Angular. Cette librairie vient désormais avec de nouvelles fonctionnalités pour gérer l'état de notre application :
signalStore, withState, withComputed, withMethods, withHooks …
Installation
@ngrx/signals
pour les fonctions de NgRx Signal Store@ngrx/operators
pour utiliser les opérations RxJS avec NgRx Signal Store
npm install @ngrx/signals --save
npm install @ngrx/operators --save
Création du store
La fonction signalStore
permet de créer le store à partir d’un état initial. Celui-ci est rattaché au niveau root pour ne pas dépendre du cycle de vie du composant. Grâce à cette fonction, chaque propriété du store est définie comme un Signal et les sous-objets comme des Deep Signals contenant eux-mêmes des Signals.
La fonction withState
permet d’injecter l'état initial dans notre store.
Exemple d’utilisation : On initialise un store qui contient les informations de l’utilisateur, ces droits.
const initialState = {
isLoading: false,
user: {
id: 0,
firstName: 'John',
lastName: 'Doe',
address: {}
} as User,
authorities: ['READ_ONLY']
}
export const IpponStore = signalStore(
{ providedIn: 'root' },
withState(initialState)
);
Injection du store dans les composants
Le store étant défini au niveau root, il suffit de l’injecter dans nos composants comme un service pour y avoir accès.
import { Component, inject } from '@angular/core';
import { IpponStore } from '../../../features/store/ippon.store';
@Component({
selector: 'app-user-details',
standalone: true,
imports: [],
templateUrl: './user-details.component.html',
styleUrl: './user-details.component.css'
})
export class UserDetailsComponent {
readonly ipponStore = inject(IpponStore);
}
Ainsi on accède aux propriétés du store sous forme de Signals dans le template ou dans le composant.
<h1>Utilisateur</h1>
<div>
<div>Prénom : {{ipponStore.user.firstName()}}</div>
<div>Nom : {{ipponStore.user.lastName()}}</div>
<div>Prénom / Nom : {{ipponStore.name()}}</div>
</div>
<h1>Adresse</h1>
@if(ipponStore.user.address && ipponStore.user.address(); as userAddress) {
<div>
<div>Adresse1 : {{userAddress.address1}}</div>
<div>Adresse2 : {{userAddress.address2}}</div>
<div>Code Postal : {{userAddress.zip}}</div>
<div>Ville : {{userAddress.city}}</div>
<div>Pays : {{userAddress.country}}</div>
</div>
} @else {
<p>Aucune adresse</p>
}
Définition des Computed Signals
La fonction withComputed
permet de déclarer un ensemble de Computed Signals.Comme pour les Signals, les Computed Signals sont recalculés dès qu’un changement sur un des Signals qu'il utilise est détecté.
Exemple d’utilisation :
export const IpponStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ user: { firstName, lastName } }) => ({
name: computed(() => `${firstName()} ${lastName()}`),
})),
);
Ainsi, une nouvelle propriété name
est définie dans le store comme la concaténation du nom et prénom de l’utilisateur. Cette propriété est automatiquement recalculée, dès lors que le prénom ou le nom changent.
Ajout de méthodes au store
La fonction withMethods
rend possible la déclaration d'un ensemble de méthodes qui permettent d’interagir avec le store et de mettre à jour avec la méthode patchState
.
Exemple d’utilisation :
withMethods((store) => ({
reset() {
patchState(store, initialState);
},
changeName(firstName: string, lastName: string) {
patchState(store, { user: { ...store.user(), firstName, lastName } });
},
// ...
}))
Dans cet exemple, deux nouvelles méthodes (reset
, changeName
) sont ajoutées pour réinitialiser le store ou le mettre à jour. Les méthodes ou propriétés peuvent être définies private en les préfixant “_ prefix”.
Il est également possible d’utiliser RxJS au sein de ces méthodes à l’aide de la fonction rxMethod
en injectant un service dans la méthode withMethod
.
Exemple d’utilisation :
withMethods((store, userService = inject(UserService)) => ({
// ...
getAddressFromExternal: rxMethod<number>(
pipe(
tap(() => patchState(store, { isLoading: true })),
switchMap((userId) => {
return userService.getAddressByUserId(userId).pipe(
tapResponse({
next: (address) => patchState(store, { isLoading: false, user: { ...store.user(), address } }),
error: (err) => {
patchState(store, { isLoading: false });
console.error(err);
},
})
)
})
)
),
}),
Dans cet exemple, une nouvelle méthode (getAddressFromExternal
) permet d’interroger un service externe retournant un Observable d’adresse d’un utilisateur.
Toutes ces nouvelles méthodes et propriétés du store sont reconnues et accessibles par complétion dans VisualStudio.
Utilisation des hooks
La fonction withHooks
permet de lancer des actions spécifiques lors de l’initialisation ou la destruction du store (onInit
/ onDestroy
). Il est possible de passer en paramètre les propriétés ou méthodes du store afin de les utiliser.
Exemple d’utilisation :
withHooks(({ name, changeName }) => {
return {
onInit() {
console.log('init store');
},
onDestroy() {
console.log('destruction du store contenant l\'utilisateur : ', name());
changeName( '', '');
},
};
}),
Il est possible d’initialiser des effects dans la méthode onInit
des hooks qui se déclenchent dès que l'état du store change.
withHooks({
onInit(store) {
effect(() => {
// The effect is re-executed on state change.
const state = getState(store);
console.log('counter state', state);
});
},
})
Tests du store
Pour tester le store, il suffit de l’injecter de la même manière que l’on injecte un service, dans nos tests Jasmine.
import { TestBed } from '@angular/core/testing';
import { IpponStore } from './ippon.store';
describe('IpponStore', () => {
it('should verify that store is initialized', () => {
const store = TestBed.inject(IpponStore);
expect(store.name()).toEqual('John Doe');
});
});
Custom Store Features : Création de features personnalisées
Grâce au Custom Store Features, il est possible d'étendre les fonctionnalités par défaut de NgRx Signal Store. La fonction signalStoreFeature
permet de créer des features personnalisées et réutilisables dans différents stores. Toutes les fonctions de withState
, withComputed
, withMethods
etc. sont utilisables par la feature.
Exemple d’utilisation :
export function withIpponLogging() {
return signalStoreFeature(
withHooks({
onInit(store) {
effect(() => {
// The effect is re-executed on state change.
const state = getState(store);
console.log('counter state', state);
});
},
}),
);
}
Dans cet exemple, nous définissons une feature withIpponLogging
qui définit un hook pour logger le changement d’état du store.
Il reste juste à appeler notre feature dans le store :
export const IpponStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(...) => ({
...
})),
withMethods(...) => ({
...
}),
),
withIpponLogging(),
);
NgRx Toolkit
Cette librairie, actuellement en version bêta, exploite les possibilités offertes par les Custom Store Features afin d’étendre notre store avec de nouvelles fonctionnalités.
Installation :
npm install @angular-architects/ngrx-toolkit --save
Pour plus de détails : https://github.com/angular-architects/ngrx-toolkit
Conclusion
Avec NgRx Signal Store, on bénéficie d’une gestion d'état réactive et performante en utilisant des Signals. On réduit le code nécessaire pour gérer l'état, et le concept est nettement plus simple et intuitif.
Les fonctionnalités de cette librairie peuvent être aisément étendues grâce au Custom Store Features.
Même s’il n’est pas indispensable, je vous conseille vivement de l’utiliser dans vos projets nécessitant de maintenir un état partagé entre vos composants.
Référence
Lien GitHub du POC : ippon-signal-store
NgRx Store
https://angular.fr/ngrx/
https://ngrx.io/guide/store
NgRx Signal Store
https://www.stefanos-lignos.dev/posts/ngrx-signals-store
https://offering.solutions/blog/articles/2023/12/03/ngrx-signal-store-getting-started/
https://ngrx.io/guide/signals/signal-store
https://ngrx.io/guide/signals/signal-store/custom-store-features
https://ngrx.io/guide/signals/signal-store/testing
https://github.com/angular-architects/ngrx-toolkit
https://www.angulararchitects.io/blog/ngrx-signal-store-deep-dive-flexible-and-type-safe-custom-extensions/