Angular course · Module 10: State Management
State Management - The Shogun's Treasury
In this lesson2
Managing state is like running the shogun's treasury - it requires order, control, and precise accounting of every resource. In feudal Japan, the shogun had to know the state of his resources at all times.
What is Application State?
State is all the data that an application stores:
- UI State - whether a modal is open, the active tab
- Server State - data from API (users, products)
- Form State - form values and validation
- Router State - current URL path
Simple State Management with 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)
}));
}
}The shogun's treasury works only when it has one way in and one way out - keep the state in a private signal, expose nothing to the outside but computed selectors, and let every change pass through an action that builds a new state object rather than quietly overwriting the old one.
Code for this lesson: src/app/app.component.ts
import { Component, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// Simple Store with 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 (without NgRx - pure 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 }));
// API simulation
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>Shogun's Treasury - State Management</h1>
<div class="treasury-display">
<span class="gold-icon">¥</span>
<span class="gold-amount">{{ store.treasury() | number }}</span>
<span class="gold-label">gold</span>
</div>
</header>
<main>
<section class="controls">
<button (click)="store.loadSamurai()" [disabled]="store.loading()">
{{ store.loading() ? '⏳ Loading...' : 'Load Army' }}
</button>
<button (click)="store.addToTreasury(500)">
Add 500 gold
</button>
<div class="hire-form">
<input #nameInput placeholder="Samurai name" value="New Warrior">
<input #clanInput placeholder="Clan" value="Tokugawa">
<button (click)="store.hireSamurai(nameInput.value, clanInput.value)">
Hire (200)
</button>
</div>
</section>
<section class="stats">
<div class="stat-card">
<div class="stat-value">{{ store.totalSamurai() }}</div>
<div class="stat-label">Samurai</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ store.totalGoldInArmy() | number }}</div>
<div class="stat-label">Gold in army</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ store.richestSamurai()?.name || '-' }}</div>
<div class="stat-label">Richest</div>
</div>
</section>
<div class="main-content">
<section class="samurai-list">
<h2>Samurai Army</h2>
@if (store.loading()) {
<div class="loading">Loading list...</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">Click "Load Army" to start</p>
}
}
</section>
<section class="detail-panel">
<h2>Details</h2>
@if (store.selectedSamurai(); as samurai) {
<div class="detail-content">
<h3>{{ samurai.name }}</h3>
<p><strong>Clan:</strong> {{ samurai.clan }}</p>
<p><strong>Gold:</strong> {{ samurai.gold }}</p>
<div class="equipment">
<h4>Equipment:</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)">
Give 50 gold
</button>
<button class="danger" (click)="store.dismissSamurai(samurai.id)">
Dismiss
</button>
</div>
</div>
} @else {
<p class="empty">Select a samurai from the list</p>
}
</section>
</div>
</main>
<footer>
<h3>How does State Management work?</h3>
<div class="explanation">
<div class="step">
<span class="num">1</span>
<span>State stores all the data</span>
</div>
<div class="step">
<span class="num">2</span>
<span>Selectors (computed) pick the needed data</span>
</div>
<div class="step">
<span class="num">3</span>
<span>Actions change state in a controlled way</span>
</div>
<div class="step">
<span class="num">4</span>
<span>UI updates automatically</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();
}Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the difference between signal() and computed() in the context of state management?
2. Which of the following is an example of Server State in an application?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Complete the simple store for a list of samurai using signals.
- Horizontal ordering
Arrange the import statement for signal and computed from the Angular library (one line, left to right):
- Click in order
Click the elements in order to build a call that updates a signal with a new value (adding an item to a list):
- Vertical ordering
Order the stages of the loadSamurai() action in a Signals-based store (top to bottom):