Angular course ยท Module 10: State Management
Project: The treasury of state
In this lesson9
Welcome to the vaults beneath Osaka Castle. You already know three ways to run a treasury: a guild with signals, SignalStore and classic NgRx. Now the shogun hands the keys to you.
The Academy keeps a register of samurai that three screens use at once: the dojo list, the header counter and the recruitment form. If each kept its own copy, a new recruit would appear in the form while the list and the counter still knew nothing about him. Three copies mean three truths, and the treasury must have one. So you will build a single place that every screen reads from and reports its changes through. Along the way you will use everything this module has taught you.
Step 1: the inventory of compartments
Every treasury starts with an inventory. A samurai, described by the Samurai interface, is an object with a number id, a name and a clan. The TreasuryState interface describes four compartments: the samurai list, the clanFilter, the loading flag and an error field for news of a failure.
interface Samurai {
id: number;
name: string;
clan: string;
}
interface TreasuryState {
samurai: Samurai[];
clanFilter: string;
loading: boolean;
error: string | null;
}The list, the flag and the error are server state from the first lesson of this module, while the filter is UI state. An interface stores nothing and disappears after compilation - it only makes sure that nothing outside the inventory gets into the treasury. Read string | null as "text or nothing": no error is null, not an empty string.
Step 2: the treasury at dawn
We write the initial state into a separate constant, initialState, because we need it twice: in the guild and in the SignalStore version.
const initialState: TreasuryState = {
samurai: [],
clanFilter: '',
loading: false,
error: null
};At dawn the treasury is empty, and we agree to read the empty filter as "all clans". The TreasuryState annotation keeps the set complete: delete the line with the error field and the compiler complains at once. There are no samurai here yet - the courier brings the register in step six.
Step 3: only the treasurer holds the key
The treasurer is the TreasuryStore guild. The import covers everything we will use: signal and computed from the smoke signals module, inject from the guilds module and the HttpClient courier from the couriers module. The @Injectable decorator with the providedIn: 'root' option means one guild for the whole kingdom, and the word private hides the state from the rest of the app.
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class TreasuryStore {
private state = signal<TreasuryState>(initialState);
}Every screen gets the same treasury, not a copy. Only the treasurer holds the key: a component that writes store.state.set(...) will not compile. For now, though, the treasury has no windows at all, so nobody can read anything from it.
Step 4: windows for the heralds
Heralds are there to read the state, not to toss in coins. Add computed windows to the class, each with the word readonly, as in the first lesson of the module. The first three show one compartment each, and the last two are derived: count counts the samurai, and visibleSamurai keeps the chosen clan or, when the filter is empty, the whole list - that is how to read the clan ? ... : ... condition.
readonly samurai = computed(() => this.state().samurai);
readonly loading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error);
readonly count = computed(() => this.samurai().length);
readonly visibleSamurai = computed(() => {
const clan = this.state().clanFilter;
return clan ? this.samurai().filter(s => s.clan === clan) : this.samurai();
});A computed window has no set() or update(), and readonly only makes sure that nobody swaps the window itself. We do not keep the number of samurai or the filtered list in the state: whatever can be computed is computed by the scribe, so the counter can never drift away from the list. A bonus: when only loading changes, the samurai window returns the same array, so count does not even recompute.
Step 5: the treasurer's orders
Changes enter the treasury through a single door: the guild's actions. The addSamurai, removeSamurai and setClanFilter actions work as in the first lesson of the module: update() takes the old state and returns a new object in which ...s copies every compartment and one gets overwritten.
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(x => x.id !== id) }));
}
setClanFilter(clanFilter: string): void {
this.state.update(s => ({ ...s, clanFilter }));
}This is writing a new scroll instead of crossing out a line in the old one: the three dots and filter always build a new array, and the old state object does not change by a single comma. Resist the temptation of s.samurai.push(samurai), because that is the trap from the smoke signals module: the array stays the same and no window notices the change.
Step 6: a courier for the register
The loadSamurai() action raises the loading flag, clears the old error, sends the courier out with get() typed as Samurai[] and writes the result when he returns. The courier joins the guild through inject(HttpClient), and subscribe() gets two branches: next with the list he brought and error with the failure, from which we take the message field.
private http = inject(HttpClient);
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 }))
});
}Both branches lower the flag, so the loading indicator can never get stuck. A failure does not touch the samurai compartment, so the list from the previous load stays on screen next to the message. There is no subscription to clean up: the courier brings one answer and the stream closes by itself.
Step 7: the treasury in the dojo
The treasury's first client is a component. You know the @Component decorator, the @for loop with track and the (click) event from earlier modules. The store field gets the treasury through inject(), the template reads the windows with parentheses, and a click gives an order.
@Component({
selector: 'app-dojo-roster',
template: `
<h2>In the treasury: {{ store.count() }}</h2>
@for (s of store.visibleSamurai(); track s.id) {
<button (click)="store.removeSamurai(s.id)">{{ s.name }}</button>
}
`
})
export class DojoRosterComponent {
readonly store = inject(TreasuryStore);
}The component has no state of its own, and yet the counter counts everyone, the list shows the chosen clan, and a click dismisses a samurai from both views at once. You start the load as in the SignalStore lesson, by calling this.store.loadSamurai() in the constructor, and you show the store.loading() flag with an @if block. The component imports neither signal nor HttpClient, because all knowledge of the state lives in the guild. The header counter and the recruitment form will inject the same guild.
Step 8: the same treasury as a SignalStore
The link task lets you choose the second road, so let us build TreasurySignalStore. The imports are as in the SignalStore lesson: signalStore, withState, withComputed, withMethods and patchState from @ngrx/signals, plus computed from @angular/core. Our initialState is the starting state.
export const TreasurySignalStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(store => ({
count: computed(() => store.samurai().length)
})),
withMethods(store => ({
addSamurai(samurai: Samurai): void {
patchState(store, state => ({ samurai: [...state.samurai, samurai] }));
}
}))
);The withState block replaces the private signal and opens a window onto every compartment by itself, so you get store.samurai() without writing a computed. The derived windows move into withComputed, the orders into withMethods, and patchState copies the remaining compartments for you - you set the filter with the line patchState(store, { clanFilter }). The rule stays the same: from outside you read windows and call methods. You will find more in the NgRx SignalStore documentation.
Which treasury to choose
I recommend the guild with signals: in a small treasury it shows the key, the windows and the orders to the naked eye. Reach for SignalStore once you have several treasuries and catch yourself repeating the same skeleton. Leave classic NgRx to great kingdoms, because an army against a single register is ceremony with no gain.
Put the project on StackBlitz or in a repository and submit the link in the next task. Before you send it, check three things: private state, computed windows and actions that create a new state object. In the next module, The Art of War, you will put the treasury to the test: it has no template, so you can check it with a test without drawing a single screen.
Remember: the treasury has one key and many windows - you lock the state in a private signal, show it through computed, and change it only with orders that write a new scroll instead of crossing out the old one.
Docs entries used in this lesson
Spotted a mistake in this lesson?