Kurs Angular · Moduł 10: Zarządzanie stanem

Projekt: Skarbiec stanu

7 min czytania ·
W tej lekcji9

Witaj w podziemiach zamku Osaka. Poznałeś już trzy sposoby prowadzenia skarbca: gildię z sygnałami, SignalStore i klasyczny NgRx. Teraz shogun wręcza klucze Tobie.

Akademia prowadzi rejestr samurajów, z którego korzystają naraz trzy ekrany: lista w dojo, licznik w nagłówku i formularz werbunku. Gdyby każdy trzymał własną kopię, nowy rekrut pojawiłby się w formularzu, a lista i licznik dalej by o nim nie wiedziały. Trzy kopie to trzy prawdy, a skarbiec ma mieć jedną. Zbudujesz więc jedno miejsce, z którego wszystkie ekrany czytają i przez które zgłaszają zmiany. Po drodze użyjesz wszystkiego, czego nauczył Cię ten moduł.

Krok 1: spis przegródek

Każdy skarbiec zaczyna się od spisu. Samuraj, opisany interfejsem Samurai, to obiekt z numerem id, imieniem name i klanem clan. Interfejs TreasuryState opisuje cztery przegródki: listę samurai, filtr klanu clanFilter, flagę loading oraz pole error na wiadomość o porażce.

interface Samurai {
  id: number;
  name: string;
  clan: string;
}

interface TreasuryState {
  samurai: Samurai[];
  clanFilter: string;
  loading: boolean;
  error: string | null;
}

Lista, flaga i błąd to stan serwera z pierwszej lekcji modułu, a filtr to stan interfejsu. Interfejs niczego nie przechowuje i po kompilacji znika - pilnuje tylko, żeby do skarbca nie trafiło nic spoza spisu. Zapis string | null znaczy "tekst albo nic": brak błędu to null, a nie pusty napis.

Krok 2: skarbiec o świcie

Stan początkowy zapisujemy w osobnej stałej initialState, bo przyda się dwa razy: w gildii i w wersji SignalStore.

const initialState: TreasuryState = {
  samurai: [],
  clanFilter: '',
  loading: false,
  error: null
};

O świcie skarbiec jest pusty, a pusty filtr umawiamy się czytać jako "wszystkie klany". Adnotacja TreasuryState pilnuje kompletu: usuń linię z polem error, a kompilator od razu zaprotestuje. Samurajów jeszcze tu nie ma - rejestr przyniesie goniec w szóstym kroku.

Krok 3: klucz ma tylko skarbnik

Skarbnikiem zostaje gildia TreasuryStore. Import obejmuje od razu wszystko, czego użyjemy: signal i computed z modułu o sygnałach dymnych, inject z modułu o gildiach i gońca HttpClient z modułu o gońcach. Dekorator @Injectable z opcją providedIn: 'root' oznacza jedną gildię na całe królestwo, a słowo private chowa stan przed resztą aplikacji.

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);
}

Każdy ekran dostanie ten sam skarbiec, a nie kopię. Klucz ma tylko skarbnik: komponent, który napisze store.state.set(...), nie przejdzie kompilacji. Na razie jednak skarbiec nie ma ani jednego okna, więc nikt niczego z niego nie odczyta.

Krok 4: okna dla heroldów

Heroldzi mają czytać stan, a nie dorzucać monet. Dopisz w klasie okna computed, każde ze słowem readonly jak w pierwszej lekcji modułu. Trzy pierwsze pokazują po jednej przegródce, a dwa ostatnie są pochodne: count liczy samurajów, a visibleSamurai zostawia wybrany klan albo, gdy filtr jest pusty, całą listę - tak czytaj warunek clan ? ... : ....

  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();
  });

Okno computed nie ma set() ani update(), a readonly pilnuje tylko, by nikt nie podmienił samego okna. Liczby samurajów ani przefiltrowanej listy nie trzymamy w stanie: to, co da się policzyć, liczy skryba, więc licznik nigdy nie rozjedzie się z listą. Bonus: gdy zmienia się tylko loading, okno samurai zwraca tę samą tablicę, więc count nawet się nie przelicza.

Krok 5: rozkazy skarbnika

Zmiany wchodzą do skarbca jedynymi drzwiami: akcjami gildii. Akcje addSamurai, removeSamurai i setClanFilter działają jak w pierwszej lekcji modułu: update() bierze stary stan i zwraca nowy obiekt, w którym ...s przepisuje wszystkie przegródki, a jedna zostaje nadpisana.

  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 }));
  }

To spisanie nowego zwoju zamiast skreślania linijki w starym: trzy kropki i filter zawsze tworzą nową tablicę, a stary obiekt stanu nie zmienia się ani o przecinek. Nie ulegaj pokusie s.samurai.push(samurai), bo to pułapka z modułu o sygnałach dymnych: tablica zostaje ta sama i żadne okno nie zauważy zmiany.

Krok 6: goniec po rejestr

Akcja loadSamurai() zapala flagę loading, czyści stary błąd, wysyła gońca metodą get() z typem Samurai[] i wpisuje wynik, gdy ten wróci. Gońca do gildii sprowadza inject(HttpClient), a subscribe() dostaje dwie gałęzie: next z przyniesioną listą i error z porażką, z której bierzemy pole message.

  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 }))
    });
  }

Obie gałęzie gaszą flagę, więc wskaźnik ładowania nigdy nie utknie. Porażka nie rusza przegródki samurai, więc lista z poprzedniego wczytania zostaje na ekranie obok komunikatu. Subskrypcji nie trzeba sprzątać: goniec przynosi jedną odpowiedź i strumień sam się zamyka.

Krok 7: skarbiec w dojo

Pierwszym odbiorcą jest komponent. Dekorator @Component, pętlę @for z track i zdarzenie (click) znasz z wcześniejszych modułów. Pole store dostaje skarbiec przez inject(), szablon czyta okna nawiasami, a kliknięcie wydaje rozkaz.

@Component({
  selector: 'app-dojo-roster',
  template: `
    <h2>W skarbcu: {{ 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);
}

Komponent nie ma własnego stanu, a jednak licznik liczy wszystkich, lista pokazuje wybrany klan, a kliknięcie odsyła samuraja w obu widokach naraz. Wczytanie odpalisz jak w lekcji o SignalStore, wołając this.store.loadSamurai() w konstruktorze, a flagę store.loading() pokażesz blokiem @if. Komponent nie importuje ani signal, ani HttpClient, bo cała wiedza o stanie mieszka w gildii. Licznik w nagłówku i formularz werbunku wstrzykną tę samą gildię.

Krok 8: ten sam skarbiec w SignalStore

Zadanie z linkiem pozwala wybrać drugą drogę, więc zbudujmy TreasurySignalStore. Importy są jak w lekcji o SignalStore: signalStore, withState, withComputed, withMethods i patchState z @ngrx/signals, a computed z @angular/core. Stanem startowym jest nasz initialState.

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] }));
    }
  }))
);

Blok withState zastępuje prywatny sygnał i sam otwiera okno na każdą przegródkę, więc store.samurai() masz bez pisania computed. Okna pochodne przejmuje withComputed, rozkazy withMethods, a patchState przepisuje resztę przegródek za Ciebie - filtr ustawisz linią patchState(store, { clanFilter }). Zasada zostaje ta sama: z zewnątrz czytasz okna i wołasz metody. Więcej znajdziesz w dokumentacji NgRx SignalStore.

Który skarbiec wybrać

Polecam Ci gildię z sygnałami: w małym skarbcu widać w niej gołym okiem klucz, okna i rozkazy. Po SignalStore sięgnij, gdy skarbców zrobi się kilka i zaczniesz powtarzać ten sam szkielet. Klasyczny NgRx zostaw wielkim królestwom, bo armia przeciw jednemu rejestrowi to ceremonia bez zysku.

Umieść projekt na StackBlitz albo w repozytorium i prześlij link w następnym zadaniu. Przed wysłaniem sprawdź trzy rzeczy: prywatny stan, okna computed i akcje, które tworzą nowy obiekt stanu. W kolejnym module, Sztuce Wojny, wystawisz skarbiec na próbę: nie ma szablonu, więc sprawdzisz go testem bez rysowania ekranu.

Pamiętaj: skarbiec ma jeden klucz i wiele okien - stan zamykasz w prywatnym sygnale, pokazujesz przez computed, a zmieniasz tylko rozkazami, które spisują nowy zwój zamiast skreślać stary.

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Przydatne artykuły