Cours Angular · Module 10: Gestion d'état
SignalStore (NGRX 17+)
NgRx SignalStore est une bibliothèque moderne de gestion d'état basée sur les Signals.
Installation et notions de base
1npm install @ngrx/signals1import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
2import { computed, inject } from '@angular/core';
3
4interface SamuraiState {
5 samurai: Samurai[];
6 loading: boolean;
7 filter: string;
8}
9
10const initialState: SamuraiState = {
11 samurai: [],
12 loading: false,
13 filter: ''
14};
15
16export const SamuraiStore = signalStore(
17 { providedIn: 'root' },
18
19 // État
20 withState(initialState),
21
22 // Signals calculés
23 withComputed((store) => ({
24 filteredSamurai: computed(() => {
25 const filter = store.filter().toLowerCase();
26 return store.samurai().filter(s =>
27 s.name.toLowerCase().includes(filter)
28 );
29 }),
30 count: computed(() => store.samurai().length)
31 })),
32
33 // Méthodes (actions)
34 withMethods((store, http = inject(HttpClient)) => ({
35 loadSamurai(): void {
36 patchState(store, { loading: true });
37
38 http.get<Samurai[]>('/api/samurai').subscribe({
39 next: (samurai) => patchState(store, { samurai, loading: false }),
40 error: () => patchState(store, { loading: false })
41 });
42 },
43
44 setFilter(filter: string): void {
45 patchState(store, { filter });
46 },
47
48 addSamurai(samurai: Samurai): void {
49 patchState(store, (state) => ({
50 samurai: [...state.samurai, samurai]
51 }));
52 }
53 }))
54);
55
56// Utilisation dans un composant
57@Component({
58 template: `
59 <input (input)="store.setFilter($any($event.target).value)">
60 @for (s of store.filteredSamurai(); track s.id) {
61 <div>{{ s.name }}</div>
62 }
63 `
64})
65export class SamuraiListComponent {
66 store = inject(SamuraiStore);
67
68 constructor() {
69 this.store.loadSamurai();
70 }
71}Code de cette leçon : src/app/app.component.ts
1import { Component, signal } from '@angular/core';
2import { CommonModule } from '@angular/common';
3
4@Component({
5 selector: 'app-root',
6 standalone: true,
7 imports: [CommonModule],
8 template: `
9 <div class="ngrx-guide">
10 <h1>NgRx SignalStore - Guide</h1>
11
12 <section>
13 <h2>Qu’est-ce que NgRx SignalStore ?</h2>
14 <p>NgRx SignalStore est une bibliothèque moderne de gestion d’état qui s’appuie sur les Signals d’Angular. Elle est plus simple que le NgRx Store classique.</p>
15
16 <div class="comparison">
17 <div class="box classic">
18 <h3>NgRx classique</h3>
19 <ul>
20 <li>Actions</li>
21 <li>Reducers</li>
22 <li>Effects</li>
23 <li>Sélecteurs</li>
24 <li>Beaucoup de boilerplate</li>
25 </ul>
26 </div>
27
28 <div class="box signal">
29 <h3>NgRx SignalStore</h3>
30 <ul>
31 <li>withState()</li>
32 <li>withComputed()</li>
33 <li>withMethods()</li>
34 <li>Moins de code</li>
35 <li>Des Signals !</li>
36 </ul>
37 </div>
38 </div>
39 </section>
40
41 <section>
42 <h2>Créer un SignalStore</h2>
43 <pre><code>{{ storeCode }}</code></pre>
44 </section>
45
46 <section>
47 <h2>Utilisation dans un composant</h2>
48 <pre><code>{{ componentCode }}</code></pre>
49 </section>
50
51 <section>
52 <h2>Effets asynchrones</h2>
53 <pre><code>{{ effectsCode }}</code></pre>
54 </section>
55
56 <section>
57 <h2>Configuration</h2>
58 <pre><code>{{ configCode }}</code></pre>
59 </section>
60
61 <div class="tips">
62 <h2>Conseils</h2>
63 <ul>
64 <li><strong>Quand utiliser SignalStore ?</strong> Quand l’état est partagé entre de nombreux composants.</li>
65 <li><strong>Quand les Signals suffisent-ils ?</strong> Pour l’état local d’un composant.</li>
66 <li><strong>Immutabilité :</strong> crée toujours de nouveaux objets au lieu de modifier ceux qui existent.</li>
67 <li><strong>Sélecteurs :</strong> utilise withComputed() pour les données dérivées.</li>
68 </ul>
69 </div>
70 </div>
71 `,
72 styles: [`
73 .ngrx-guide {
74 font-family: 'Segoe UI', sans-serif;
75 padding: 20px;
76 background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
77 min-height: 100vh;
78 color: #eee;
79 }
80
81 h1 {
82 text-align: center;
83 color: #64ffda;
84 }
85
86 h2 {
87 color: #64ffda;
88 border-bottom: 2px solid #64ffda;
89 padding-bottom: 10px;
90 }
91
92 section {
93 background: rgba(0,0,0,0.3);
94 border-radius: 10px;
95 padding: 20px;
96 margin: 20px 0;
97 }
98
99 pre {
100 background: #0d1421;
101 padding: 15px;
102 border-radius: 8px;
103 overflow-x: auto;
104 border-left: 4px solid #64ffda;
105 }
106
107 code {
108 color: #e6e6e6;
109 font-family: 'Fira Code', monospace;
110 font-size: 0.85em;
111 line-height: 1.5;
112 }
113
114 .comparison {
115 display: grid;
116 grid-template-columns: 1fr 1fr;
117 gap: 20px;
118 margin: 20px 0;
119 }
120
121 @media (max-width: 600px) {
122 .comparison {
123 grid-template-columns: 1fr;
124 }
125 }
126
127 .box {
128 padding: 15px;
129 border-radius: 10px;
130 }
131
132 .box.classic {
133 background: rgba(255, 107, 107, 0.2);
134 border: 2px solid #ff6b6b;
135 }
136
137 .box.signal {
138 background: rgba(100, 255, 218, 0.2);
139 border: 2px solid #64ffda;
140 }
141
142 .box h3 {
143 margin-top: 0;
144 }
145
146 .box ul {
147 margin: 0;
148 padding-left: 20px;
149 }
150
151 .box li {
152 margin: 5px 0;
153 }
154
155 .tips {
156 background: rgba(100, 255, 218, 0.1);
157 border: 2px solid #64ffda;
158 border-radius: 10px;
159 padding: 20px;
160 }
161
162 .tips ul {
163 list-style: none;
164 padding: 0;
165 }
166
167 .tips li {
168 padding: 10px 0;
169 border-bottom: 1px solid rgba(100, 255, 218, 0.2);
170 }
171
172 .tips li:last-child {
173 border-bottom: none;
174 }
175 `]
176})
177export class AppComponent {
178 storeCode = `// samurai.store.ts
179import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
180import { computed } from '@angular/core';
181
182interface SamuraiState {
183 samuraiList: Samurai[];
184 selectedId: number | null;
185 loading: boolean;
186}
187
188const initialState: SamuraiState = {
189 samuraiList: [],
190 selectedId: null,
191 loading: false
192};
193
194export const SamuraiStore = signalStore(
195 // État initial
196 withState(initialState),
197
198 // Sélecteurs (computed)
199 withComputed((store) => ({
200 selectedSamurai: computed(() => {
201 const id = store.selectedId();
202 return store.samuraiList().find(s => s.id === id) ?? null;
203 }),
204 totalCount: computed(() => store.samuraiList().length),
205 totalGold: computed(() =>
206 store.samuraiList().reduce((sum, s) => sum + s.gold, 0)
207 )
208 })),
209
210 // Méthodes (actions)
211 withMethods((store) => ({
212 selectSamurai(id: number): void {
213 patchState(store, { selectedId: id });
214 },
215
216 addSamurai(samurai: Samurai): void {
217 patchState(store, {
218 samuraiList: [...store.samuraiList(), samurai]
219 });
220 },
221
222 removeSamurai(id: number): void {
223 patchState(store, {
224 samuraiList: store.samuraiList().filter(s => s.id !== id),
225 selectedId: store.selectedId() === id ? null : store.selectedId()
226 });
227 }
228 }))
229);`;
230
231 componentCode = `// samurai-list.component.ts
232import { Component, inject } from '@angular/core';
233import { SamuraiStore } from './samurai.store';
234
235@Component({
236 selector: 'app-samurai-list',
237 standalone: true,
238 providers: [SamuraiStore], // Ou dans app.config.ts pour un état global
239 template: \`
240 <div>
241 <p>Nombre de samouraïs : {{ store.totalCount() }}</p>
242 <p>Or total : {{ store.totalGold() }}</p>
243
244 @for (samurai of store.samuraiList(); track samurai.id) {
245 <div (click)="store.selectSamurai(samurai.id)">
246 {{ samurai.name }} - {{ samurai.gold }} pièces d’or
247 </div>
248 }
249
250 @if (store.selectedSamurai(); as selected) {
251 <div class="detail">
252 Sélectionné : {{ selected.name }}
253 </div>
254 }
255 </div>
256 \`
257})
258export class SamuraiListComponent {
259 store = inject(SamuraiStore);
260}`;
261
262 effectsCode = `// samurai.store.ts - avec des effets HTTP
263import { signalStore, withState, withMethods } from '@ngrx/signals';
264import { rxMethod } from '@ngrx/signals/rxjs-interop';
265import { inject } from '@angular/core';
266import { HttpClient } from '@angular/common/http';
267import { pipe, switchMap, tap } from 'rxjs';
268
269export const SamuraiStore = signalStore(
270 withState(initialState),
271
272 withMethods((store, http = inject(HttpClient)) => ({
273 // Méthode avec RxJS
274 loadSamurai: rxMethod<void>(
275 pipe(
276 tap(() => patchState(store, { loading: true })),
277 switchMap(() => http.get<Samurai[]>('/api/samurai')),
278 tap(samuraiList => patchState(store, {
279 samuraiList,
280 loading: false
281 }))
282 )
283 ),
284
285 // Méthode async/await
286 async loadSamuraiAsync(): Promise<void> {
287 patchState(store, { loading: true });
288
289 const response = await fetch('/api/samurai');
290 const samuraiList = await response.json();
291
292 patchState(store, { samuraiList, loading: false });
293 }
294 }))
295);`;
296
297 configCode = `// app.config.ts
298import { ApplicationConfig } from '@angular/core';
299import { SamuraiStore } from './stores/samurai.store';
300
301export const appConfig: ApplicationConfig = {
302 providers: [
303 // Store global - disponible dans toute l’application
304 SamuraiStore,
305
306 // Ou avec des feature flags
307 provideStore(),
308 provideState({ name: 'samurai', reducer: samuraiReducer }),
309 provideEffects(SamuraiEffects)
310 ]
311};`;
312}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. À quoi sert la fonction patchState() dans NgRx SignalStore ?
2. Que définit la fonction withState() dans signalStore() ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne l'appel signalStore avec ses principaux blocs dans leur ordre d'écriture habituel (une seule ligne, de gauche à droite) :
- Éditeur de code
Complète le SignalStore pour gérer les samouraïs.
- Tri horizontal
Ordonne la commande pour installer la bibliothèque NgRx SignalStore (une seule ligne, de gauche à droite) :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire un champ qui injecte un SignalStore dans un composant (Angular moderne) :