Cours Angular · Module 10: Gestion d'état
La gestion d'état - Le trésor du Shogun
Dans cette leçon2
Gérer l'état, c'est comme diriger le trésor du Shogun : cela exige de l'ordre, du contrôle et une comptabilité précise de chaque ressource. Dans le Japon féodal, le Shogun devait connaître à tout moment l'état de ses ressources.
Qu'est-ce que l'état d'une application ?
L'état, c'est l'ensemble des données qu'une application conserve :
- État de l'UI - si une modale est ouverte, l'onglet actif
- État serveur - données provenant de l'API (utilisateurs, produits)
- État de formulaire - valeurs du formulaire et validation
- État du routeur - chemin d'URL actuel
Gestion d'état simple avec les Signals
1import { Injectable, signal, computed, inject } from '@angular/core';
2import { HttpClient } from '@angular/common/http';
3
4interface SamuraiState {
5 samurai: Samurai[];
6 selectedId: number | null;
7 loading: boolean;
8 error: string | null;
9}
10
11@Injectable({ providedIn: 'root' })
12export class SamuraiStore {
13 private http = inject(HttpClient);
14
15 // Signals d'état privés
16 private state = signal<SamuraiState>({
17 samurai: [],
18 selectedId: null,
19 loading: false,
20 error: null
21 });
22
23 // Sélecteurs publics (lecture seule)
24 readonly samurai = computed(() => this.state().samurai);
25 readonly selectedId = computed(() => this.state().selectedId);
26 readonly loading = computed(() => this.state().loading);
27 readonly error = computed(() => this.state().error);
28
29 // Sélecteurs dérivés
30 readonly selectedSamurai = computed(() => {
31 const id = this.selectedId();
32 return id ? this.samurai().find(s => s.id === id) : null;
33 });
34
35 readonly samuraiCount = computed(() => this.samurai().length);
36
37 readonly samuraiByClan = computed(() => {
38 const grouped = new Map<string, Samurai[]>();
39 for (const s of this.samurai()) {
40 const list = grouped.get(s.clan) || [];
41 list.push(s);
42 grouped.set(s.clan, list);
43 }
44 return grouped;
45 });
46
47 // Actions (mutations d'état)
48 loadSamurai(): void {
49 this.state.update(s => ({ ...s, loading: true, error: null }));
50
51 this.http.get<Samurai[]>('/api/samurai').subscribe({
52 next: (samurai) => {
53 this.state.update(s => ({ ...s, samurai, loading: false }));
54 },
55 error: (err) => {
56 this.state.update(s => ({
57 ...s,
58 loading: false,
59 error: err.message
60 }));
61 }
62 });
63 }
64
65 selectSamurai(id: number | null): void {
66 this.state.update(s => ({ ...s, selectedId: id }));
67 }
68
69 addSamurai(samurai: Samurai): void {
70 this.state.update(s => ({
71 ...s,
72 samurai: [...s.samurai, samurai]
73 }));
74 }
75
76 removeSamurai(id: number): void {
77 this.state.update(s => ({
78 ...s,
79 samurai: s.samurai.filter(sam => sam.id !== id)
80 }));
81 }
82}Le trésor du Shogun ne tient que s'il n'a qu'une entrée et qu'une sortie : garde l'état dans un signal privé, n'expose vers l'extérieur que des sélecteurs computed, et fais passer chaque modification par une action qui construit un nouvel objet d'état au lieu d'écraser silencieusement l'ancien.
Code de cette leçon : src/app/app.component.ts
1import { Component, signal, computed, inject } from '@angular/core';
2import { CommonModule } from '@angular/common';
3
4// Store simple avec les Signals
5interface Samurai {
6 id: number;
7 name: string;
8 clan: string;
9 gold: number;
10 equipment: string[];
11}
12
13interface AppState {
14 samuraiList: Samurai[];
15 selectedSamurai: Samurai | null;
16 treasury: number;
17 loading: boolean;
18}
19
20// Signal Store (sans NgRx - du pur Angular)
21class SamuraiStore {
22 // État
23 private state = signal<AppState>({
24 samuraiList: [],
25 selectedSamurai: null,
26 treasury: 10000,
27 loading: false
28 });
29
30 // Sélecteurs (computed)
31 samuraiList = computed(() => this.state().samuraiList);
32 selectedSamurai = computed(() => this.state().selectedSamurai);
33 treasury = computed(() => this.state().treasury);
34 loading = computed(() => this.state().loading);
35
36 // Sélecteurs dérivés
37 totalSamurai = computed(() => this.samuraiList().length);
38 totalGoldInArmy = computed(() =>
39 this.samuraiList().reduce((sum, s) => sum + s.gold, 0)
40 );
41 richestSamurai = computed(() => {
42 const list = this.samuraiList();
43 if (list.length === 0) return null;
44 return list.reduce((richest, s) => s.gold > richest.gold ? s : richest);
45 });
46
47 // Actions
48 loadSamurai(): void {
49 this.state.update(s => ({ ...s, loading: true }));
50
51 // Simulation de l’API
52 setTimeout(() => {
53 const mockData: Samurai[] = [
54 { id: 1, name: 'Musashi', clan: 'Ronin', gold: 500, equipment: ['Katana', 'Wakizashi'] },
55 { id: 2, name: 'Kojiro', clan: 'Ronin', gold: 450, equipment: ['Nodachi'] },
56 { id: 3, name: 'Hanzo', clan: 'Tokugawa', gold: 800, equipment: ['Kunai', 'Shuriken', 'Ninjato'] },
57 { id: 4, name: 'Masamune', clan: 'Date', gold: 1200, equipment: ['Katana', 'Armure'] }
58 ];
59
60 this.state.update(s => ({
61 ...s,
62 samuraiList: mockData,
63 loading: false
64 }));
65 }, 800);
66 }
67
68 selectSamurai(samurai: Samurai | null): void {
69 this.state.update(s => ({ ...s, selectedSamurai: samurai }));
70 }
71
72 addGoldToSamurai(id: number, amount: number): void {
73 if (this.treasury() < amount) return;
74
75 this.state.update(s => ({
76 ...s,
77 treasury: s.treasury - amount,
78 samuraiList: s.samuraiList.map(sam =>
79 sam.id === id ? { ...sam, gold: sam.gold + amount } : sam
80 )
81 }));
82 }
83
84 addToTreasury(amount: number): void {
85 this.state.update(s => ({ ...s, treasury: s.treasury + amount }));
86 }
87
88 hireSamurai(name: string, clan: string): void {
89 const cost = 200;
90 if (this.treasury() < cost) return;
91
92 const newSamurai: Samurai = {
93 id: Date.now(),
94 name,
95 clan,
96 gold: 50,
97 equipment: ['Bokken']
98 };
99
100 this.state.update(s => ({
101 ...s,
102 treasury: s.treasury - cost,
103 samuraiList: [...s.samuraiList, newSamurai]
104 }));
105 }
106
107 dismissSamurai(id: number): void {
108 const samurai = this.samuraiList().find(s => s.id === id);
109 if (!samurai) return;
110
111 this.state.update(s => ({
112 ...s,
113 treasury: s.treasury + Math.floor(samurai.gold * 0.5),
114 samuraiList: s.samuraiList.filter(sam => sam.id !== id),
115 selectedSamurai: s.selectedSamurai?.id === id ? null : s.selectedSamurai
116 }));
117 }
118}
119
120@Component({
121 selector: 'app-root',
122 standalone: true,
123 imports: [CommonModule],
124 template: `
125 <div class="treasury-app">
126 <header>
127 <h1>Le trésor du Shogun - gestion d’état</h1>
128
129 <div class="treasury-display">
130 <span class="gold-icon">¥</span>
131 <span class="gold-amount">{{ store.treasury() | number }}</span>
132 <span class="gold-label">pièces d’or</span>
133 </div>
134 </header>
135
136 <main>
137 <section class="controls">
138 <button (click)="store.loadSamurai()" [disabled]="store.loading()">
139 {{ store.loading() ? 'Chargement...' : 'Charger l’armée' }}
140 </button>
141
142 <button (click)="store.addToTreasury(500)">
143 Ajouter 500 pièces d’or
144 </button>
145
146 <div class="hire-form">
147 <input #nameInput placeholder="Nom du samouraï" value="Nouveau guerrier">
148 <input #clanInput placeholder="Clan" value="Tokugawa">
149 <button (click)="store.hireSamurai(nameInput.value, clanInput.value)">
150 Recruter (200)
151 </button>
152 </div>
153 </section>
154
155 <section class="stats">
156 <div class="stat-card">
157 <div class="stat-value">{{ store.totalSamurai() }}</div>
158 <div class="stat-label">Samouraïs</div>
159 </div>
160
161 <div class="stat-card">
162 <div class="stat-value">{{ store.totalGoldInArmy() | number }}</div>
163 <div class="stat-label">Or dans l’armée</div>
164 </div>
165
166 <div class="stat-card">
167 <div class="stat-value">{{ store.richestSamurai()?.name || '-' }}</div>
168 <div class="stat-label">Le plus riche</div>
169 </div>
170 </section>
171
172 <div class="main-content">
173 <section class="samurai-list">
174 <h2>Armée de samouraïs</h2>
175
176 @if (store.loading()) {
177 <div class="loading">Chargement de la liste...</div>
178 } @else {
179 @for (samurai of store.samuraiList(); track samurai.id) {
180 <div
181 class="samurai-row"
182 [class.selected]="store.selectedSamurai()?.id === samurai.id"
183 (click)="store.selectSamurai(samurai)"
184 >
185 <div class="samurai-info">
186 <span class="name">{{ samurai.name }}</span>
187 <span class="clan">{{ samurai.clan }}</span>
188 </div>
189 <div class="samurai-gold">{{ samurai.gold }}</div>
190 <div class="samurai-actions">
191 <button class="small" (click)="store.addGoldToSamurai(samurai.id, 100); $event.stopPropagation()">
192 +100
193 </button>
194 <button class="small danger" (click)="store.dismissSamurai(samurai.id); $event.stopPropagation()">
195 ×
196 </button>
197 </div>
198 </div>
199 } @empty {
200 <p class="empty">Clique sur "Charger l’armée" pour commencer</p>
201 }
202 }
203 </section>
204
205 <section class="detail-panel">
206 <h2>Détails</h2>
207
208 @if (store.selectedSamurai(); as samurai) {
209 <div class="detail-content">
210 <h3>{{ samurai.name }}</h3>
211 <p><strong>Clan :</strong> {{ samurai.clan }}</p>
212 <p><strong>Or :</strong> {{ samurai.gold }}</p>
213
214 <div class="equipment">
215 <h4>Équipement :</h4>
216 <ul>
217 @for (item of samurai.equipment; track item) {
218 <li>{{ item }}</li>
219 }
220 </ul>
221 </div>
222
223 <div class="detail-actions">
224 <button (click)="store.addGoldToSamurai(samurai.id, 50)">
225 Donner 50 pièces d’or
226 </button>
227 <button class="danger" (click)="store.dismissSamurai(samurai.id)">
228 Renvoyer
229 </button>
230 </div>
231 </div>
232 } @else {
233 <p class="empty">Choisis un samouraï dans la liste</p>
234 }
235 </section>
236 </div>
237 </main>
238
239 <footer>
240 <h3>Comment fonctionne la gestion d’état ?</h3>
241 <div class="explanation">
242 <div class="step">
243 <span class="num">1</span>
244 <span>L’état stocke toutes les données</span>
245 </div>
246 <div class="step">
247 <span class="num">2</span>
248 <span>Les sélecteurs (computed) choisissent les données utiles</span>
249 </div>
250 <div class="step">
251 <span class="num">3</span>
252 <span>Les actions modifient l’état de façon contrôlée</span>
253 </div>
254 <div class="step">
255 <span class="num">4</span>
256 <span>L’UI se met à jour automatiquement</span>
257 </div>
258 </div>
259 </footer>
260 </div>
261 `,
262 styles: [`
263 .treasury-app {
264 font-family: 'Segoe UI', sans-serif;
265 background: linear-gradient(135deg, #2c1810 0%, #1a0f0a 100%);
266 min-height: 100vh;
267 color: #f4e4bc;
268 }
269
270 header {
271 background: linear-gradient(90deg, #8b4513 0%, #654321 100%);
272 padding: 20px;
273 display: flex;
274 justify-content: space-between;
275 align-items: center;
276 flex-wrap: wrap;
277 gap: 15px;
278 }
279
280 h1 {
281 margin: 0;
282 color: #ffd700;
283 text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
284 }
285
286 .treasury-display {
287 display: flex;
288 align-items: center;
289 gap: 10px;
290 background: rgba(0,0,0,0.3);
291 padding: 10px 20px;
292 border-radius: 30px;
293 border: 2px solid #ffd700;
294 }
295
296 .gold-icon {
297 font-size: 1.5em;
298 }
299
300 .gold-amount {
301 font-size: 1.5em;
302 font-weight: bold;
303 color: #ffd700;
304 }
305
306 main {
307 padding: 20px;
308 }
309
310 .controls {
311 display: flex;
312 gap: 15px;
313 flex-wrap: wrap;
314 margin-bottom: 20px;
315 }
316
317 button {
318 padding: 10px 20px;
319 background: #8b4513;
320 color: #f4e4bc;
321 border: 2px solid #ffd700;
322 border-radius: 5px;
323 cursor: pointer;
324 transition: all 0.3s;
325 }
326
327 button:hover:not(:disabled) {
328 background: #a0522d;
329 transform: translateY(-2px);
330 }
331
332 button:disabled {
333 opacity: 0.5;
334 cursor: not-allowed;
335 }
336
337 button.small {
338 padding: 5px 10px;
339 font-size: 0.85em;
340 }
341
342 button.danger {
343 background: #8b0000;
344 border-color: #ff4444;
345 }
346
347 .hire-form {
348 display: flex;
349 gap: 10px;
350 }
351
352 .hire-form input {
353 padding: 10px;
354 border: 2px solid #654321;
355 border-radius: 5px;
356 background: #3c2415;
357 color: #f4e4bc;
358 }
359
360 .stats {
361 display: grid;
362 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
363 gap: 15px;
364 margin-bottom: 20px;
365 }
366
367 .stat-card {
368 background: linear-gradient(135deg, #654321 0%, #3c2415 100%);
369 padding: 15px;
370 border-radius: 10px;
371 text-align: center;
372 border: 1px solid #8b4513;
373 }
374
375 .stat-value {
376 font-size: 1.8em;
377 font-weight: bold;
378 color: #ffd700;
379 }
380
381 .stat-label {
382 color: #a89070;
383 font-size: 0.9em;
384 }
385
386 .main-content {
387 display: grid;
388 grid-template-columns: 1fr 300px;
389 gap: 20px;
390 }
391
392 @media (max-width: 768px) {
393 .main-content {
394 grid-template-columns: 1fr;
395 }
396 }
397
398 section {
399 background: rgba(0,0,0,0.3);
400 border-radius: 10px;
401 padding: 15px;
402 border: 1px solid #654321;
403 }
404
405 h2 {
406 margin-top: 0;
407 color: #ffd700;
408 border-bottom: 2px solid #8b4513;
409 padding-bottom: 10px;
410 }
411
412 .samurai-row {
413 display: flex;
414 align-items: center;
415 gap: 15px;
416 padding: 10px;
417 margin: 5px 0;
418 background: rgba(139, 69, 19, 0.3);
419 border-radius: 5px;
420 cursor: pointer;
421 transition: all 0.3s;
422 }
423
424 .samurai-row:hover {
425 background: rgba(139, 69, 19, 0.5);
426 }
427
428 .samurai-row.selected {
429 background: rgba(255, 215, 0, 0.2);
430 border: 1px solid #ffd700;
431 }
432
433 .samurai-info {
434 flex: 1;
435 }
436
437 .samurai-info .name {
438 font-weight: bold;
439 display: block;
440 }
441
442 .samurai-info .clan {
443 font-size: 0.85em;
444 color: #a89070;
445 }
446
447 .samurai-gold {
448 font-weight: bold;
449 color: #ffd700;
450 }
451
452 .samurai-actions {
453 display: flex;
454 gap: 5px;
455 }
456
457 .loading, .empty {
458 text-align: center;
459 color: #a89070;
460 padding: 30px;
461 }
462
463 .detail-content h3 {
464 color: #ffd700;
465 margin-top: 0;
466 }
467
468 .equipment h4 {
469 margin-bottom: 5px;
470 }
471
472 .equipment ul {
473 margin: 0;
474 padding-left: 20px;
475 }
476
477 .detail-actions {
478 display: flex;
479 gap: 10px;
480 margin-top: 15px;
481 }
482
483 footer {
484 background: rgba(0,0,0,0.5);
485 padding: 20px;
486 margin-top: 20px;
487 }
488
489 footer h3 {
490 color: #ffd700;
491 text-align: center;
492 }
493
494 .explanation {
495 display: flex;
496 justify-content: center;
497 flex-wrap: wrap;
498 gap: 20px;
499 }
500
501 .step {
502 display: flex;
503 align-items: center;
504 gap: 10px;
505 background: rgba(139, 69, 19, 0.3);
506 padding: 10px 15px;
507 border-radius: 5px;
508 }
509
510 .step .num {
511 background: #ffd700;
512 color: #2c1810;
513 width: 25px;
514 height: 25px;
515 border-radius: 50%;
516 display: flex;
517 align-items: center;
518 justify-content: center;
519 font-weight: bold;
520 }
521 `]
522})
523export class AppComponent {
524 store = new SamuraiStore();
525}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. Quelle est la différence entre signal() et computed() dans le contexte de la gestion d'état ?
2. Lequel des éléments suivants est un exemple de Server State dans une application ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Complète le store simple pour une liste de samouraïs en utilisant des signals.
- Tri horizontal
Ordonne l'instruction d'import de signal et computed depuis la bibliothèque Angular (une seule ligne, de gauche à droite) :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire un appel qui met à jour un signal avec une nouvelle valeur (ajout d'un élément à une liste) :
- Tri vertical
Ordonne les étapes de l'action loadSamurai() dans un store basé sur les Signals (de haut en bas) :