DEV Community

Cover image for Arrête d’utiliser SignalStore pour tout : la hype passe, la dette reste
Romain Geffrault
Romain Geffrault

Posted on

Arrête d’utiliser SignalStore pour tout : la hype passe, la dette reste

On va parler sérieusement, ça ne va sans doute pas plaire à beaucoup de monde, mais à un moment il faut mettre un peu de côté la hype et se concentrer sur les faits.

Il faut arrêter de voir le signalStore de la team NgRx comme le sacro-saint outil de la gestion d'état dans Angular.

Alors oui, comme on va le voir, le signalStore a de gros avantages, mais en pratique et de la façon dont il est mis en avant, ça va dans le mauvais sens.

Pourquoi je le critique ?

  1. Comme on peut le voir d'après les stats, il est de plus en plus populaire, donc de plus en plus de devs l'utilisent et le découvrent
  2. Il y a énormément de conférences pour le promouvoir, et c'est très bien, mais il faut aussi parler de ses limites
  3. Le signalStore n'a pas laissé beaucoup de place à l'émergence de nouveaux patterns liés aux signals avec leur aspect réactif et leur forte intégration dans Angular. Et il s'est presque imposé comme la bonne façon de travailler avec les signaux, ce qui est dommage.
  4. Mis en avant pour son côté qui s'adapte à tout tel un bon couteau suisse, mais si ce n'était pas le cas ?

Quand on utilise un outil, il faut être conscient de ses avantages et de ses limites (sinon le risque est de se retrouver avec une dette technique dissimulée par le fait que l'outil est "cool" ou "à la mode").

Dans ce sens, je vais aussi mettre en avant des patterns basés sur les Signals très peu populaires, mais dont le signalStore passe à côté.

Cet article s'adresse à tous ceux qui l'utilisent ou souhaitent l'utiliser, et qui ne voient pas encore comment ça peut mal tourner (en espérant que ce ne soit pas trop tard).

Mais avant toute chose, je tiens à féliciter l'équipe de NgRx pour le travail qu'ils ont fait, c'est un outil très puissant qui a d'énormes avantages, et qui ouvre la voie à un nouveau style de gestion d'état dans Angular.

Je m'en suis énormément inspiré pour faire mes propres outils de gestion d'état, et je pense que c'est une très bonne chose pour la communauté Angular.

Par où commencer ? Vous aimez les ressources d'Angular ?

Eh bien, je vais vous raconter une anecdote avec un jeune passionné d'Angular, Simon.

À son travail, le signal store est implémenté partout, et on en vient à parler des ressources et de comment les utiliser.

Personnellement, ça faisait un moment que je n'utilisais plus le signal store au profit d'autres patterns, ce qui a engendré des incompréhensions dans notre discussion, mais sans doute pas comme vous l'imaginez.

L'objectif était d'avoir une page où on affiche les détails d'un User en fonction d'un paramètre (id) venant de l'URL.

Ma vision de la solution :

  • l'id est récupéré par le component/page via un input (et l'utilisation de withComponentInputBindings)
  • On utilise une resource en lui passant en params cet id sous forme de signal (dès qu'il change, cela va déclencher un appel API associé à ce nouvel id)

Rien de compliqué, c'est une utilisation très classique des ressources.

// component/page
@Component()
class UserDetails {
  id = input.required<string>();

  userService = inject(UserService);

  userDetailsResource = resource({
    params: this.id,
    loader: (id) => this.userService.getUserDetails(id),
  });
}
Enter fullscreen mode Exit fullscreen mode

Simon voulait éviter de gérer la logique dans le composant directement pour "découper les responsabilités" et ça collait mieux au reste du projet.

Traduction, on va passer par un signalStore pour faire l'appel API.

Vous avez déjà vu les exemples du signalStore pour faire des appels API ? 😅

Vous avez déjà remarqué la façon dont sont gérés les server-state dans le signalStore en suivant les exemples officiels de NgRx ?

On y reviendra, pour l'instant, l'idée reste que le comportement attendu est : l'id dans mon URL change / il faut que j'appelle l'API pour récupérer les nouveaux détails du User.

Voici une potentielle solution (plein de variantes existent mais le problème reste le même) :

Côté signalStore, on peut imaginer une implémentation comme celle-ci. Elle semble correspondre à ce qui est attendu.
L'id change => appel API pour récupérer les nouveaux détails du User.

const userDetailsStore = signalStore(
  withState({ id: undefined as string | undefined }),
  withProps(({ id }, userService = inject(UserService)) => ({
    userDetails: resource({
      params: id,
      loader: ({ params: id }) => userService.getUserDetails(id),
    }),
  })),
);
Enter fullscreen mode Exit fullscreen mode

Puis je lui dis : maintenant, tu passes l'id récupéré par ton composant à ton signalStore.

Et là, j'ai mis du temps à comprendre pourquoi il galérait autant à faire cette étape.

Comment passer un input à un signalStore ?

En vrai, comment on fait pour passer un input à un signalStore ? C'est une question très simple, mais la réponse est trop compliquée pour ce qui est attendu.

Si je ne dis pas de bêtises, une solution pour le faire ressemble à ça, où on expose une méthode pour mettre à jour l'id.

const UserDetailsStore = signalStore(
  withState({ id: undefined as string | undefined }),
  withProps(({ id }, userService = inject(UserService)) => ({
    userDetails: resource({
      params: id,
      loader: ({ params: id }) => userService.getUserDetails(id),
    }),
  })),
  withMethods((store) => ({
    setId: (id: string) => patchStore(store, { id }), // méthode pour mettre à jour l'id dans le state du signalStore
  })),
);
Enter fullscreen mode Exit fullscreen mode

Côté composant, on va devoir faire un effect pour appeler cette méthode à chaque fois que l'id change.

@Component({ providers: [UserDetailsStore] }) // créer une instance du store pour ce composant, qui se détruira à la destruction du composant
class UserDetails {
  id = input.required<string>();
  userDetailsStore = inject(UserDetailsStore);

  _syncIdWithStore = effect(() => {
    this.userDetailsStore.setId(this.id());
  });
}
Enter fullscreen mode Exit fullscreen mode

C'est quand même lourd comme implémentation pour extraire la logique de notre composant, vous ne trouvez pas ?

Ps: C'est une utilisation d'un effect approprié pour cette solution

On doit provider le store, l'injecter, faire un effect pour synchroniser l'id du composant avec le store, et faire en sorte d'exposer une méthode du store pour faire la mise à jour de l'id.

Suite à ça, je lui ai dit : attends, je vais te montrer une alternative pour extraire la logique du composant et ça va être follement simple.

Extraire la logique d'un composant à l'ère des Signals

function userDetailsResource(id: Signal<string>) {
  const userService = inject(UserService);

  return resource({
    params: id,
    loader: ({ params: id }) => userService.getUserDetails(id),
  });
}
Enter fullscreen mode Exit fullscreen mode
@Component()
class UserDetails {
  id = input.required<string>();

  userDetails = userDetailsResource(this.id);
}
Enter fullscreen mode Exit fullscreen mode

Et voilà. Alors oui, ce n'est pas un portage 1 pour 1, car on ne peut pas injecter userDetails dans des composants enfants. Mais à aucun moment ça n'a été difficile d'écrire une fonction qui extrait la logique de notre composant.

Et ça, on ne le voit que très peu, car c'est un pattern qui est très simplifié depuis le passage des inputs en signal.

Si tu tiens vraiment à passer par le système d'injection pour le récupérer dans des composants enfants, tu peux aussi faire ça grâce aux outils que j'ai mis en place (craft-ng)

export const { injectUserDetailsQuery, provideUserDetailsQuery } = craftService(
  { name: "UserDetailsQuery", scope: "toProvid" },
  (id: Signal<string>) => {
    const userService = inject(UserService);

    return resource({
      params: id,
      loader: (id) => userService.getUserDetails(id),
    });
  },
);
Enter fullscreen mode Exit fullscreen mode
@Component({ providers: [provideUserDetailsQuery()] })
class UserDetails {
  id = input.required<string>();

  userDetailsQuery = injectUserDetailsQuery(this.id);
}
Enter fullscreen mode Exit fullscreen mode

Voilà, il n'y a presque pas de complexité ajoutée.

3 grosses limites du signalStore venant de notre exemple

1 - Le passage d'inputs à un signalStore est compliqué / ajout de charge mentale

Comme on l'a vu, il faut ajouter beaucoup de boilerplate pour faire le lien entre un input et un signalStore.

Cela ajoute une charge mentale pour comprendre le flow de données, et ce qui se passe dans notre app.

Il est possible de créer un utilitaire pour faciliter la liaison entre un input et un signalStore, j'en ai partagé différentes versions dans mes posts.

2 - Le signalStore n'est pas adapté pour gérer les server states

J'ai été gentil, j'ai utilisé les resources pour gérer le server state (user details) dans le signal store.
Mais quel a été l'intérêt du signal store, sa plus-value ? Rien si ce n'est que ça nous a compliqué la vie pour déclencher les appels API.

Si on reprend l'exemple officiel, qui met directement la gestion d'un server state avec l'utilisation du signalStore:

export const BookSearchStore = signalStore(
  withState(initialState),
  withMethods((store, booksService = inject(BooksService)) => ({
    /* ... */
    // 👇 Defining a method to load all books.
    async loadAll(): Promise<void> {
      patchState(store, { isLoading: true });

      const books = await booksService.getAll();
      patchState(store, { books, isLoading: false });
    },
  })),
);
Enter fullscreen mode Exit fullscreen mode

Sans un utilitaire similaire à une resource, on se retrouve à devoir gérer manuellement l'état de chargement, les erreurs, etc., ce qui est lourd et fastidieux.

Et qui dans le web est très courant...

Et pourquoi ça fonctionne "mal", c'est que le signalStore considère l'état client (son withState) comme source de vérité, il ne peut pas être dérivé à partir d'une resource, ou d'un state venant de l'URL. Ce qui le contraint à devoir faire des synchronisations forcées entre des sources et son state interne.

Certains apprécient ce pattern, moi je trouve que l'abstraction du signalStore n'est ni appropriée ni adaptable.

Ce n'est pas digne d'un couteau suisse, ça a un intérêt comme on va le voir après, mais pas pour un outil à tout faire.

Si t'as encore un doute, regarde un peu les exemples que tu peux trouver sur les différents articles, tu verras que c'est très lourd pour un pattern qui a été résolu depuis un moment grâce à des outils comme les ressources, TanStack Query, etc.

Dans mes outils, j'ai un utilitaire query qui me permet de considérer l'équivalent d'une resource comme source de vérité, et d'y associer des méthodes/computed pour interagir avec l'état récupéré.

J'ai le même mécanisme pour les query params avec queryParam.

Et ça marche bien mieux d'un point de vue DX.

Mais qu'en est-il du point suivant ?

3 - Testabilité & tracking des dépendances

Côté testabilité, le signalStore se teste comme n'importe quel autre service. Il a donc les mêmes avantages et les mêmes inconvénients que les services d'Angular classiques.

Un autre problème que je n'ai pas signalé, mais qui me pose problème bien que ce soit accepté par la communauté.

C'est le fait que l'on ne tracke pas les dépendances (services injectés) dans le signalStore.

Les services d'Angular actuels présentent cette même faiblesse, mais on voit que le signalStore ne la compense pas.

Pourtant, tracker les dépendances simplifie grandement la testabilité (s'assurer que les dépendances sont bien provider, ou mocker).

À l'instar des composants standalone où on importe juste ce qu'il faut pour le composant, le tracking des dépendances permet de provider et mocker uniquement ce qu'il faut.

On retire une dépendance ? Une erreur TS apparaît dans les tests, car on essaye de provider ou mocker une dépendance qui n'existe plus.

Ça assure que le test rate pour les bonnes raisons et pas à cause de problème de configuration provider/mock.

J'ai aussi résolu ce problème avec mes outils, je te laisse aller voir si ça t'intéresse via craftService

Le couteau suisse commence à présenter quelques faiblesses...

Faut-il jeter le signalStore à la poubelle ? Non

Gestion des client states

Après avoir exposé quelques limites, voici ce sur quoi il est bon, même très bon par rapport à la plupart des solutions de gestion d'état dans Angular.

Il est très adapté pour gérer 1 état client.

J'ai bien dit : 1 état client.

1 :
Donc pas plusieurs, mais 1 seul.

Client:
Donc pas un server state, pas un état venant d'une resource, pas un état venant de l'url.

Pourquoi pas de server state ? Bien que le signalStore puisse être adapté pour gérer du server state (des données venant d'une API), cela entraîne beaucoup de boilerplate pour des cas courants, où il est facile d'oublier la gestion des erreurs. D'autant plus que, côté gestion des erreurs, que ce soit avec les Observables ou les Promises, les erreurs ne sont pas typées, ce qui rend la gestion des erreurs métier plus compliquée. Ces erreurs peuvent venir d'une perte de connexion, ou d'un accès refusé à une ressource, etc. Et si on regarde la très grande majorité des exemples d'appels API, la gestion des erreurs est catastrophique (pour ne pas dire que l'auteur s'en bat les couilles). Et je pense que ça vient du fait que NgRx n'apporte aucun outil qui aide réellement à gérer les appels API/asynchrones, en déléguant cette responsabilité à l'utilisateur.

Pourquoi il est bon à gérer les états clients ?

L'intérêt c'est qu'on part d'un état initial typé.

Cela veut dire qu'il aura toujours la même forme, mais que ces propriétés pourront être mises à jour.

Et comment vont-elles être mises à jour ?

Par des méthodes qui vont faire des patchs sur le state du signalStore.

Ou encore via des réactions à des events.

Et c'est trop bien, dans le signalStore lui-même on peut voir exactement comment va évoluer notre state.

L'exposition de méthodes est pratique et il est tout aussi simple de passer sur des réactions à des events ou un mix des deux.

Ça peut être utilisé comme un bon compromis entre une approche method-based et event-based (principe lié à Redux).

L'autre intérêt est qu'on peut aussi exposer des états dérivés directement liés via les computed.

On a tout en même endroit, ce qui aide, je trouve, à piloter la logique et à visualiser les différentes évolutions de notre état.

Maintenant, parlons d'un autre "problème" qu'a résolu le signalStore, par rapport à son prédécesseur (global store) ?

Scope global ou local mais pas trop...

Une limitation du global store, c'est qu'il était surtout fait pour gérer des états globaux. Et il le faisait plutôt bien.

Toutefois, quand on architecture une application, on n'a pas que besoin d'états globaux, mais aussi d'états locaux à une feature, ou même à un composant.

(J'avoue ne pas être sûr de ne pas dire de bêtises sur ce point, n'hésitez pas à me corriger dans les commentaires).

Le signalStore pouvant avoir une portée globale ou plus locale, il semble plus adapté pour architecturer une application.

Et c'est tout à son honneur.

Mais encore une fois, il ne fait pas mieux que les services d'Angular.

On peut oublier de le provider et avoir une erreur au runtime, qui conceptuellement pourrait être évitée.

Un autre reproche lié à ce sujet, mais dont je pense que l'équipe NgRx peut s'adapter, c'est qu'il n'est pas possible d'utiliser le signalStore en inline dans un composant comme on peut le faire avec les ressources.

Pourtant, ça permettrait d'avoir une sorte de signal amélioré où tout est déclaré explicitement sur la façon dont le composant interagit avec.

Cela lèverait aussi les problèmes du passage d'inputs, d'obligation de devoir le provider...

Ça simplifierait grandement la création d'un signalStore par état, plutôt qu'il soit composé de plusieurs pas forcément liés.

Ça devrait, selon moi, être son mode par défaut.

Et si on en parlait de la composition souvent mise en avant ?

La composition du signalStore top ou flop ?

Eh bien, en réalité, je la trouve très intéressante.

Et elle permet la réutilisation de logique de manière très simple.

Toutefois (oui, j'ai des trucs à dire aussi à ce sujet 😂), le choix de pouvoir composer un store avec des features qui, elles, contiennent des states, méthodes, computed et réactions, c'est très bien, mais...

C'est un choix qui peut être très intéressant pour faire du partage de logique, mais qui peut aussi amener à dénaturer l'intérêt du store.

On en arrive à voir des signalStore qui vont servir de façade.

Où le signalStore n'a juste aucune plus-value et rend même les choses plus compliquées.

Comme on peut le voir dans cet exemple, la façade est gérée par un service classique, qui pourrait aussi être une fonction (merci à @lucas Garcia sur Linkedin pour sa critique de la composition du signal store sur le Discord > Bonjour Angular, qui m'a inspiré).

@Service()
class MyFacade() {
    public readonly state1 = inject(State1); // State1 créé via signalStore
    public readonly state2 = inject(State2); // State2 créé via signalStore

    public readonly derivedState = computed(() => {
        // logique de dérivation à partir de state1 et state2
    });

}
Enter fullscreen mode Exit fullscreen mode

Elle est où la complexité ? À quel moment le signalStore a vraiment un intérêt à composer plusieurs states différents ?

Presque pas d'intérêt.

Ma règle : le signalStore gère un state, pas plusieurs.

Et on ne s'en sert pas pour faire une façade, ou tout ce qui peut s'en approcher.

Et personnellement, pour éviter d'en arriver à ces dérives, je préfère composer un state uniquement via de la composition de logique et d'états dérivés (pas de state ajouté via la composition). Cf. ma primitive state.

Ça permet de garder une responsabilité claire de ce que doit faire notre "conteneur" de gestion d'état.

Et ce principe, je l'applique aussi à mes autres primitives query, mutation, queryParam qui, à l'instar du Signal Form, dérivent la logique à partir de l'état de la source.

D'ailleurs, si tu penses que le système d'events compense les autres points faibles, j'ai aussi un point à critiquer.

Les events du signalStore ne sont pas si déclaratifs que ça...

J'ai clairement du mal avec les events de NgRx, et l'approche avec le signalStore, bien qu'il fasse sans doute mieux que son papa, a toujours ce gros défaut.

On ne peut pas dériver un event (avec des utilitaires officiels). Non, on doit lui donner une description pour dire ce que ça représente.

Cet event est soit dispatché suite à une action d'un utilisateur, soit depuis un effect qui fait des calculs puis émet un event.

Et ce sont ces derniers évents qui me posent problème.

Plutôt que de devoir passer par une description, on pourrait directement écrire le code qui déclenche l'event au moment de sa déclaration.

Ça permet d'avoir un event beaucoup plus déclaratif, et d'éviter un effect qui doit être provider pour être actif.

Et en plus, si ce n'est pas utilisé, c'est tree-shaké par le compilateur, ce qui n'est pas le cas d'un event classique.

Après avoir dit tout ça, je tiens à préciser que l'approche event-based est toujours une des approches les plus intéressantes pour gérer un état et la logique métier.

Et qu'il est préjudiciable de ne pas faire d'event-based dans un projet parce que les events ne sont pas déclaratifs.

Côté typage

Je ne vais pas trop entrer dans les détails, mais même si le pattern utilisé par le signal store est très intéressant, et sans doute précurseur, il a aussi des limites.

J'ai énormément travaillé avec. J'avais recréé tout ce mécanisme d'accumulation, car je le trouvais fascinant.

J'ai ensuite créé des outils de gestion de server state dédiés au signal store, et je peux vous dire que c'est très, très compliqué.

Personnellement, je trouve qu'il y a une couche en trop dans le typage, ce qui rend extrêmement difficile la création de patterns custom.

Et quand on souhaite travailler avec des features génériques basées sur le pattern principal withX, de mémoire, ce n'est pas possible sans utiliser deux utilitaires.

J'avais fait une proposition pour simplifier withFeature, mais elle a été rejetée, car elle ne gère pas mieux que leur mécanisme actuel les features avec un paramètre générique.

Lien vers un de mes articles, pour créer des features personnalisées avec une bonne DX avec le signalStore.

Je tenais à mentionner ce point, car il fait partie des limites de ce que permet de faire le signalStore.

Mais ce n'est pas vraiment lui qui pose vraiment problème.

Conclusion

Le signalStore a permis la découverte d'un pattern très intéressant autour de la composition.

Il est roi pour gérer un état client où il offre une bonne DX, et c'est déjà pas mal.

Mais comme on l'a vu, ce n'est pas un couteau suisse.

Il ne remplace pas un outil spécialisé pour gérer les états liés à l'URL ou encore pour gérer les server states (les resources actuelles ne sont pas sans défaut non plus).

(craft-ng fait tout ça 🤫)

Si tu devais continuer à l'utiliser, je te conseillerais de toujours l'utiliser pour gérer un état client, pas plus.

D'éviter le plus possible de faire des injections dedans (ça reste ok, si c'est pour intéragir avec le navigateur, ex: appel api, stockage local, etc.).

Et de ne pas l'utiliser pour faire des façades ou des compositions/orchestrateurs de plusieurs states.

De cette façon, tu garderas toujours les bons côtés de celui-ci et tu pourras profiter d'autres patterns souvent plus appropriés.


Je suis Romain Geffrault.
Développeur Angular et créateur de @craft-ng
Suis-moi pour plus de contenu sur Angular

Docs: https://ng-angular-stack.github.io/craft/

Top comments (0)