Kurs Angular · Moduł 10: Zarządzanie stanem

State Management - Skarbiec Shoguna

2 min czytania ·
W tej lekcji2

Zarządzanie stanem to jak prowadzenie skarbca shoguna - wymaga porządku, kontroli i precyzyjnej ewidencji każdego zasobu. W feudalnej Japonii shogun musiał znać stan swoich zasobów w każdej chwili.

Czym jest Stan Aplikacji?

Stan to wszystkie dane, które aplikacja przechowuje:

  • UI State - czy modal jest otwarty, aktywna zakładka
  • Server State - dane z API (użytkownicy, produkty)
  • Form State - wartości i walidacja formularzy
  • Router State - aktualna ścieżka URL

Prosty State Management z Signals

import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface SamuraiState {
  samurai: Samurai[];
  selectedId: number | null;
  loading: boolean;
  error: string | null;
}

@Injectable({ providedIn: 'root' })
export class SamuraiStore {
  private http = inject(HttpClient);

  // Private state signals
  private state = signal<SamuraiState>({
    samurai: [],
    selectedId: null,
    loading: false,
    error: null
  });

  // Public selectors (readonly)
  readonly samurai = computed(() => this.state().samurai);
  readonly selectedId = computed(() => this.state().selectedId);
  readonly loading = computed(() => this.state().loading);
  readonly error = computed(() => this.state().error);

  // Derived selectors
  readonly selectedSamurai = computed(() => {
    const id = this.selectedId();
    return id ? this.samurai().find(s => s.id === id) : null;
  });

  readonly samuraiCount = computed(() => this.samurai().length);

  readonly samuraiByClan = computed(() => {
    const grouped = new Map<string, Samurai[]>();
    for (const s of this.samurai()) {
      const list = grouped.get(s.clan) || [];
      list.push(s);
      grouped.set(s.clan, list);
    }
    return grouped;
  });

  // Actions (state mutations)
  loadSamurai(): void {
    this.state.update(s => ({ ...s, loading: true, error: null }));

    this.http.get<Samurai[]>('/api/samurai').subscribe({
      next: (samurai) => {
        this.state.update(s => ({ ...s, samurai, loading: false }));
      },
      error: (err) => {
        this.state.update(s => ({
          ...s,
          loading: false,
          error: err.message
        }));
      }
    });
  }

  selectSamurai(id: number | null): void {
    this.state.update(s => ({ ...s, selectedId: id }));
  }

  addSamurai(samurai: Samurai): void {
    this.state.update(s => ({
      ...s,
      samurai: [...s.samurai, samurai]
    }));
  }

  removeSamurai(id: number): void {
    this.state.update(s => ({
      ...s,
      samurai: s.samurai.filter(sam => sam.id !== id)
    }));
  }
}

Skarbiec shoguna działa tylko wtedy, gdy ma jedno wejście i jedno wyjście - stan trzymaj w prywatnym sygnale, na zewnątrz wystawiaj wyłącznie selektory computed, a każdą zmianę przeprowadzaj akcją, która tworzy nowy obiekt stanu zamiast po cichu nadpisywać stary.

Kod do tej lekcji: src/app/app.component.ts
import { Component, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';

// Prosty Store z Signals
interface Samurai {
  id: number;
  name: string;
  clan: string;
  gold: number;
  equipment: string[];
}

interface AppState {
  samuraiList: Samurai[];
  selectedSamurai: Samurai | null;
  treasury: number;
  loading: boolean;
}

// Signal Store (bez NgRx - czysty Angular)
class SamuraiStore {
  // State
  private state = signal<AppState>({
    samuraiList: [],
    selectedSamurai: null,
    treasury: 10000,
    loading: false
  });

  // Selectors (computed)
  samuraiList = computed(() => this.state().samuraiList);
  selectedSamurai = computed(() => this.state().selectedSamurai);
  treasury = computed(() => this.state().treasury);
  loading = computed(() => this.state().loading);

  // Derived selectors
  totalSamurai = computed(() => this.samuraiList().length);
  totalGoldInArmy = computed(() =>
    this.samuraiList().reduce((sum, s) => sum + s.gold, 0)
  );
  richestSamurai = computed(() => {
    const list = this.samuraiList();
    if (list.length === 0) return null;
    return list.reduce((richest, s) => s.gold > richest.gold ? s : richest);
  });

  // Actions
  loadSamurai(): void {
    this.state.update(s => ({ ...s, loading: true }));

    // Symulacja API
    setTimeout(() => {
      const mockData: Samurai[] = [
        { id: 1, name: 'Musashi', clan: 'Ronin', gold: 500, equipment: ['Katana', 'Wakizashi'] },
        { id: 2, name: 'Kojiro', clan: 'Ronin', gold: 450, equipment: ['Nodachi'] },
        { id: 3, name: 'Hanzo', clan: 'Tokugawa', gold: 800, equipment: ['Kunai', 'Shuriken', 'Ninjato'] },
        { id: 4, name: 'Masamune', clan: 'Date', gold: 1200, equipment: ['Katana', 'Armor'] }
      ];

      this.state.update(s => ({
        ...s,
        samuraiList: mockData,
        loading: false
      }));
    }, 800);
  }

  selectSamurai(samurai: Samurai | null): void {
    this.state.update(s => ({ ...s, selectedSamurai: samurai }));
  }

  addGoldToSamurai(id: number, amount: number): void {
    if (this.treasury() < amount) return;

    this.state.update(s => ({
      ...s,
      treasury: s.treasury - amount,
      samuraiList: s.samuraiList.map(sam =>
        sam.id === id ? { ...sam, gold: sam.gold + amount } : sam
      )
    }));
  }

  addToTreasury(amount: number): void {
    this.state.update(s => ({ ...s, treasury: s.treasury + amount }));
  }

  hireSamurai(name: string, clan: string): void {
    const cost = 200;
    if (this.treasury() < cost) return;

    const newSamurai: Samurai = {
      id: Date.now(),
      name,
      clan,
      gold: 50,
      equipment: ['Bokken']
    };

    this.state.update(s => ({
      ...s,
      treasury: s.treasury - cost,
      samuraiList: [...s.samuraiList, newSamurai]
    }));
  }

  dismissSamurai(id: number): void {
    const samurai = this.samuraiList().find(s => s.id === id);
    if (!samurai) return;

    this.state.update(s => ({
      ...s,
      treasury: s.treasury + Math.floor(samurai.gold * 0.5),
      samuraiList: s.samuraiList.filter(sam => sam.id !== id),
      selectedSamurai: s.selectedSamurai?.id === id ? null : s.selectedSamurai
    }));
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="treasury-app">
      <header>
        <h1>Skarbiec Shoguna - State Management</h1>

        <div class="treasury-display">
          <span class="gold-icon">¥</span>
          <span class="gold-amount">{{ store.treasury() | number }}</span>
          <span class="gold-label">złota</span>
        </div>
      </header>

      <main>
        <section class="controls">
          <button (click)="store.loadSamurai()" [disabled]="store.loading()">
            {{ store.loading() ? '⏳ Ładowanie...' : 'Załaduj Armię' }}
          </button>

          <button (click)="store.addToTreasury(500)">
            Dodaj 500 złota
          </button>

          <div class="hire-form">
            <input #nameInput placeholder="Imię samuraja" value="Nowy Wojownik">
            <input #clanInput placeholder="Klan" value="Tokugawa">
            <button (click)="store.hireSamurai(nameInput.value, clanInput.value)">
              Zatrudnij (200 złota)
            </button>
          </div>
        </section>

        <section class="stats">
          <div class="stat-card">
            <div class="stat-value">{{ store.totalSamurai() }}</div>
            <div class="stat-label">Samurajów</div>
          </div>

          <div class="stat-card">
            <div class="stat-value">{{ store.totalGoldInArmy() | number }}</div>
            <div class="stat-label">Złota w armii</div>
          </div>

          <div class="stat-card">
            <div class="stat-value">{{ store.richestSamurai()?.name || '-' }}</div>
            <div class="stat-label">Najbogatszy</div>
          </div>
        </section>

        <div class="main-content">
          <section class="samurai-list">
            <h2>Armia Samurajów</h2>

            @if (store.loading()) {
              <div class="loading">Ładowanie listy...</div>
            } @else {
              @for (samurai of store.samuraiList(); track samurai.id) {
                <div
                  class="samurai-row"
                  [class.selected]="store.selectedSamurai()?.id === samurai.id"
                  (click)="store.selectSamurai(samurai)"
                >
                  <div class="samurai-info">
                    <span class="name">{{ samurai.name }}</span>
                    <span class="clan">{{ samurai.clan }}</span>
                  </div>
                  <div class="samurai-gold">{{ samurai.gold }}</div>
                  <div class="samurai-actions">
                    <button class="small" (click)="store.addGoldToSamurai(samurai.id, 100); $event.stopPropagation()">
                      +100
                    </button>
                    <button class="small danger" (click)="store.dismissSamurai(samurai.id); $event.stopPropagation()">
                      ×
                    </button>
                  </div>
                </div>
              } @empty {
                <p class="empty">Kliknij "Załaduj Armię", aby rozpocząć</p>
              }
            }
          </section>

          <section class="detail-panel">
            <h2>Szczegóły</h2>

            @if (store.selectedSamurai(); as samurai) {
              <div class="detail-content">
                <h3>{{ samurai.name }}</h3>
                <p><strong>Klan:</strong> {{ samurai.clan }}</p>
                <p><strong>Złoto:</strong> {{ samurai.gold }}</p>

                <div class="equipment">
                  <h4>Ekwipunek:</h4>
                  <ul>
                    @for (item of samurai.equipment; track item) {
                      <li>{{ item }}</li>
                    }
                  </ul>
                </div>

                <div class="detail-actions">
                  <button (click)="store.addGoldToSamurai(samurai.id, 50)">
                    Daj 50 złota
                  </button>
                  <button class="danger" (click)="store.dismissSamurai(samurai.id)">
                    Zwolnij
                  </button>
                </div>
              </div>
            } @else {
              <p class="empty">Wybierz samuraja z listy</p>
            }
          </section>
        </div>
      </main>

      <footer>
        <h3>Jak działa State Management?</h3>
        <div class="explanation">
          <div class="step">
            <span class="num">1</span>
            <span>State (stan) przechowuje wszystkie dane</span>
          </div>
          <div class="step">
            <span class="num">2</span>
            <span>Selectors (computed) wybierają potrzebne dane</span>
          </div>
          <div class="step">
            <span class="num">3</span>
            <span>Actions zmieniają stan w kontrolowany sposób</span>
          </div>
          <div class="step">
            <span class="num">4</span>
            <span>UI automatycznie się aktualizuje</span>
          </div>
        </div>
      </footer>
    </div>
  `,
  styles: [`
    .treasury-app {
      font-family: 'Segoe UI', sans-serif;
      background: linear-gradient(135deg, #2c1810 0%, #1a0f0a 100%);
      min-height: 100vh;
      color: #f4e4bc;
    }

    header {
      background: linear-gradient(90deg, #8b4513 0%, #654321 100%);
      padding: 20px;
      display: flex;
      justify-content: space-between;
      align-items: center;
      flex-wrap: wrap;
      gap: 15px;
    }

    h1 {
      margin: 0;
      color: #ffd700;
      text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
    }

    .treasury-display {
      display: flex;
      align-items: center;
      gap: 10px;
      background: rgba(0,0,0,0.3);
      padding: 10px 20px;
      border-radius: 30px;
      border: 2px solid #ffd700;
    }

    .gold-icon {
      font-size: 1.5em;
    }

    .gold-amount {
      font-size: 1.5em;
      font-weight: bold;
      color: #ffd700;
    }

    main {
      padding: 20px;
    }

    .controls {
      display: flex;
      gap: 15px;
      flex-wrap: wrap;
      margin-bottom: 20px;
    }

    button {
      padding: 10px 20px;
      background: #8b4513;
      color: #f4e4bc;
      border: 2px solid #ffd700;
      border-radius: 5px;
      cursor: pointer;
      transition: all 0.3s;
    }

    button:hover:not(:disabled) {
      background: #a0522d;
      transform: translateY(-2px);
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    button.small {
      padding: 5px 10px;
      font-size: 0.85em;
    }

    button.danger {
      background: #8b0000;
      border-color: #ff4444;
    }

    .hire-form {
      display: flex;
      gap: 10px;
    }

    .hire-form input {
      padding: 10px;
      border: 2px solid #654321;
      border-radius: 5px;
      background: #3c2415;
      color: #f4e4bc;
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 15px;
      margin-bottom: 20px;
    }

    .stat-card {
      background: linear-gradient(135deg, #654321 0%, #3c2415 100%);
      padding: 15px;
      border-radius: 10px;
      text-align: center;
      border: 1px solid #8b4513;
    }

    .stat-value {
      font-size: 1.8em;
      font-weight: bold;
      color: #ffd700;
    }

    .stat-label {
      color: #a89070;
      font-size: 0.9em;
    }

    .main-content {
      display: grid;
      grid-template-columns: 1fr 300px;
      gap: 20px;
    }

    @media (max-width: 768px) {
      .main-content {
        grid-template-columns: 1fr;
      }
    }

    section {
      background: rgba(0,0,0,0.3);
      border-radius: 10px;
      padding: 15px;
      border: 1px solid #654321;
    }

    h2 {
      margin-top: 0;
      color: #ffd700;
      border-bottom: 2px solid #8b4513;
      padding-bottom: 10px;
    }

    .samurai-row {
      display: flex;
      align-items: center;
      gap: 15px;
      padding: 10px;
      margin: 5px 0;
      background: rgba(139, 69, 19, 0.3);
      border-radius: 5px;
      cursor: pointer;
      transition: all 0.3s;
    }

    .samurai-row:hover {
      background: rgba(139, 69, 19, 0.5);
    }

    .samurai-row.selected {
      background: rgba(255, 215, 0, 0.2);
      border: 1px solid #ffd700;
    }

    .samurai-info {
      flex: 1;
    }

    .samurai-info .name {
      font-weight: bold;
      display: block;
    }

    .samurai-info .clan {
      font-size: 0.85em;
      color: #a89070;
    }

    .samurai-gold {
      font-weight: bold;
      color: #ffd700;
    }

    .samurai-actions {
      display: flex;
      gap: 5px;
    }

    .loading, .empty {
      text-align: center;
      color: #a89070;
      padding: 30px;
    }

    .detail-content h3 {
      color: #ffd700;
      margin-top: 0;
    }

    .equipment h4 {
      margin-bottom: 5px;
    }

    .equipment ul {
      margin: 0;
      padding-left: 20px;
    }

    .detail-actions {
      display: flex;
      gap: 10px;
      margin-top: 15px;
    }

    footer {
      background: rgba(0,0,0,0.5);
      padding: 20px;
      margin-top: 20px;
    }

    footer h3 {
      color: #ffd700;
      text-align: center;
    }

    .explanation {
      display: flex;
      justify-content: center;
      flex-wrap: wrap;
      gap: 20px;
    }

    .step {
      display: flex;
      align-items: center;
      gap: 10px;
      background: rgba(139, 69, 19, 0.3);
      padding: 10px 15px;
      border-radius: 5px;
    }

    .step .num {
      background: #ffd700;
      color: #2c1810;
      width: 25px;
      height: 25px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: bold;
    }
  `]
})
export class AppComponent {
  store = new SamuraiStore();
}

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaka jest różnica między signal() a computed() w kontekście state management?

  2. 2. Który z poniższych jest przykładem Server State w aplikacji?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij prosty store dla listy samurajów używając signals.

  • Układanie w poziomie

    Ułóż instrukcję importu signal i computed z biblioteki Angular (jedna linia, od lewej do prawej):

  • Klikanie w kolejności

    Kliknij elementy w kolejności, aby zbudować wywołanie aktualizujące signal nową wartością (dodanie elementu do listy):

  • Układanie w pionie

    Uporządkuj etapy działania akcji loadSamurai() w store opartym o Signals (od góry do dołu):

Przydatne artykuły