Cours Angular · Module 10: Gestion d'état

La gestion d'état - Le trésor du Shogun

2 min de lecture
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. 1. Quelle est la différence entre signal() et computed() dans le contexte de la gestion d'état ?

  2. 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) :

Articles utiles