État applicatif avec NgRx SignalStore
signal() locaux, au fond des composants qui les possèdent. Le store existe pour la seule donnée qui échappe à un composant : la langue active et l'arbre de contenu qu'elle résout.
@ngrx/signals ) la remplit sans les actions ni les reducers du NgRx classique : des signals en lecture seule en sortie, des méthodes en entrée.
Composer des features
signalStore s'assemble à partir de features chaînées. withState déclare la forme et l'état initial, withComputed les valeurs dérivées, withMethods les opérations. Chaque champ d'état devient un signal exposé sur l'instance : déclarer lang produit store.lang , un Signal<Lang> que n'importe quel composant peut lire.
lang , content , loading .
patchState , qui applique une mise à jour immuable et ne notifie que les signals dont la valeur a changé. loading peut basculer sans rien re-rendre qui dépende de content .
withComputed . Ses valeurs dérivées (un titre de page, un fil d'Ariane, une liste d'articles filtrée) sont propres à chaque écran, donc elles vivent dans les composants qui les affichent, pas dans l'état partagé. La règle qui se dégage : ne centralise que ce que plusieurs vues dérivent à l'identique.
Construire l'état dans un contexte d'injection
withState accepte deux formes : un objet littéral, ou une fabrique qui le renvoie. La fabrique s'exécute dans le contexte d'injection du store, ce qui l'autorise à inject() un service et à lire localStorage au moment de la construction.
readInitialLang lit la préférence persistée, la valide comme Lang , et retombe sur la langue par défaut si localStorage est absent ou lève une exception. Volontairement, aucun reniflage de navigator.language : le prerender natif et les tests démarrent sur une langue déterministe, jamais sur celle de la machine qui construit. C'est un choix qui se paie ailleurs (un premier rendu SSG toujours en français) mais qui garde la génération statique reproductible.
Stale-while-revalidate, concrètement
peek(lang) renvoie la valeur en cache de façon synchrone : c'est elle qui amorce l'état, pour que le premier rendu et la génération statique aient déjà du contenu. getContent(lang) fait le fetch asynchrone, le vrai appel réseau à terme.
setLang échange d'abord le contenu par peek (synchrone, donc le prochain rendu est déjà dans la bonne locale), puis lance la revalidation. Le drapeau loading passe à true le temps du fetch, ce qui laisse une vue afficher un état de chargement pendant la bascule.
bundled est typé Record<Lang, Content> . Ajouter une langue au jeu de valeurs LANG ne compile plus tant que son bundle n'est pas branché ici. Le compilateur tient la liste à jour.
Annuler un résultat périmé
reload se protège par un dernier-gagne : avant d'appliquer un résultat, il vérifie que la langue courante est toujours celle qu'il a demandée.
reload('en') pendant que le store reste sur fr , avance le temps simulé de FETCH_DELAY_MS , et vérifie que le contenu final est toujours FR . Le résultat anglais est bien jeté.
Un effet à l'intérieur du store
withHooks donne au store un cycle de vie. Son onInit s'exécute dans le contexte d'injection du store, ce qui l'autorise à ouvrir un effect .
store.lang() . À chaque changement, il repersiste la préférence et met à jour l'attribut lang de <html> , celui que lisent les lecteurs d'écran et les moteurs de recherche. L'écriture localStorage est enveloppée dans un try qui avale l'erreur : un quota plein ne doit pas casser le rendu. Un test le vérifie en faisant lever setItem , en s'assurant que le tick ne jette pas, et que <html lang> passe quand même à jour.
Subscription à défaire à la main.
Une façade au-dessus du store
ContentStore directement. Ils passent par I18nService , une façade qui ne réexpose que quatre choses : lang , content , loading , setLang .
/fr , /en , …). C'est le resolver de route qui appelle setLang à partir de ce préfixe, avant que le composant ne rende. L'URL reste la seule source de vérité pour la langue : un lien partagé vers /de/articles ouvre la page en allemand sans qu'aucun état ne soit à synchroniser à la main.
Le seuil du store
signal() privé dans le composant. Y ajouter un store n'apporterait que de l'indirection.
rxMethod compris (que ce store n'utilise pas : un async / await suffisait à orchestrer un unique fetch).
Un SignalStore est une façade de signals : lecture seule en sortie, méthodes en entrée, zéro reducer. On garde la discipline d'un store, et son cycle de vie, sans le cérémonial des actions du NgRx d'hier.