Kurs Angular · Moduł 10: Zarządzanie stanem
State Management - Skarbiec Shoguna
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. Jaka jest różnica między signal() a computed() w kontekście state management?
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):