Angular et NgRx Signal Store

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

withDevTools

feature qui permet d’exploiter l’extension du Redux Devtools de Chrome, afin de suivre les changements sur le store et debugger.

withRedux

feature qui permet de combiner le pattern Redux au sein de Signal Store, en définissant des actions, reducers, effects ...

withDataService

feature qui permet de connecter le store à un fournisseur de données de type CRUD.

withStorageSync

feature qui permet de synchroniser le store avec Web Storage (local storage / session storage)

withCallState

feature qui permet de définir un statut sur une donnée du store, (init, loading, loaded,ou error) 

withUndoRedo

feature qui permet de conserver un état du store, et de revenir en arrière ou en avant si besoin. Les méthodes undo / redo sont ajoutées au store.



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/