Kurs Angular · Moduł 10: Zarządzanie stanem

SignalStore (NGRX 17+)

1 min czytania ·

NgRx SignalStore to nowoczesna, signal-based biblioteka do zarządzania stanem.

Instalacja i Podstawy

npm install @ngrx/signals
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';

interface SamuraiState {
  samurai: Samurai[];
  loading: boolean;
  filter: string;
}

const initialState: SamuraiState = {
  samurai: [],
  loading: false,
  filter: ''
};

export const SamuraiStore = signalStore(
  { providedIn: 'root' },

  // State
  withState(initialState),

  // Computed signals
  withComputed((store) => ({
    filteredSamurai: computed(() => {
      const filter = store.filter().toLowerCase();
      return store.samurai().filter(s =>
        s.name.toLowerCase().includes(filter)
      );
    }),
    count: computed(() => store.samurai().length)
  })),

  // Methods (actions)
  withMethods((store, http = inject(HttpClient)) => ({
    loadSamurai(): void {
      patchState(store, { loading: true });

      http.get<Samurai[]>('/api/samurai').subscribe({
        next: (samurai) => patchState(store, { samurai, loading: false }),
        error: () => patchState(store, { loading: false })
      });
    },

    setFilter(filter: string): void {
      patchState(store, { filter });
    },

    addSamurai(samurai: Samurai): void {
      patchState(store, (state) => ({
        samurai: [...state.samurai, samurai]
      }));
    }
  }))
);

// Użycie w komponencie
@Component({
  template: `
    <input (input)="store.setFilter($any($event.target).value)">
    @for (s of store.filteredSamurai(); track s.id) {
      <div>{{ s.name }}</div>
    }
  `
})
export class SamuraiListComponent {
  store = inject(SamuraiStore);

  constructor() {
    this.store.loadSamurai();
  }
}
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="ngrx-guide">
      <h1>NgRx SignalStore - Przewodnik</h1>

      <section>
        <h2>Czym jest NgRx SignalStore?</h2>
        <p>NgRx SignalStore to nowoczesna biblioteka do zarządzania stanem, która wykorzystuje Angular Signals. Jest prostsza niż klasyczny NgRx Store.</p>

        <div class="comparison">
          <div class="box classic">
            <h3>Klasyczny NgRx</h3>
            <ul>
              <li>Actions</li>
              <li>Reducers</li>
              <li>Effects</li>
              <li>Selectors</li>
              <li>Dużo boilerplate</li>
            </ul>
          </div>

          <div class="box signal">
            <h3>NgRx SignalStore</h3>
            <ul>
              <li>withState()</li>
              <li>withComputed()</li>
              <li>withMethods()</li>
              <li>Mniej kodu</li>
              <li>Signals!</li>
            </ul>
          </div>
        </div>
      </section>

      <section>
        <h2>Tworzenie SignalStore</h2>
        <pre><code>{{ storeCode }}</code></pre>
      </section>

      <section>
        <h2>Używanie w komponencie</h2>
        <pre><code>{{ componentCode }}</code></pre>
      </section>

      <section>
        <h2>Efekty asynchroniczne</h2>
        <pre><code>{{ effectsCode }}</code></pre>
      </section>

      <section>
        <h2>Konfiguracja</h2>
        <pre><code>{{ configCode }}</code></pre>
      </section>

      <div class="tips">
        <h2>Wskazówki</h2>
        <ul>
          <li><strong>Kiedy używać SignalStore?</strong> Gdy stan jest współdzielony między wieloma komponentami.</li>
          <li><strong>Kiedy wystarczą Signals?</strong> Dla lokalnego stanu komponentu.</li>
          <li><strong>Immutability:</strong> Zawsze twórz nowe obiekty zamiast mutować istniejące.</li>
          <li><strong>Selektory:</strong> Używaj withComputed() dla pochodnych danych.</li>
        </ul>
      </div>
    </div>
  `,
  styles: [`
    .ngrx-guide {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #64ffda;
    }

    h2 {
      color: #64ffda;
      border-bottom: 2px solid #64ffda;
      padding-bottom: 10px;
    }

    section {
      background: rgba(0,0,0,0.3);
      border-radius: 10px;
      padding: 20px;
      margin: 20px 0;
    }

    pre {
      background: #0d1421;
      padding: 15px;
      border-radius: 8px;
      overflow-x: auto;
      border-left: 4px solid #64ffda;
    }

    code {
      color: #e6e6e6;
      font-family: 'Fira Code', monospace;
      font-size: 0.85em;
      line-height: 1.5;
    }

    .comparison {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 20px;
      margin: 20px 0;
    }

    @media (max-width: 600px) {
      .comparison {
        grid-template-columns: 1fr;
      }
    }

    .box {
      padding: 15px;
      border-radius: 10px;
    }

    .box.classic {
      background: rgba(255, 107, 107, 0.2);
      border: 2px solid #ff6b6b;
    }

    .box.signal {
      background: rgba(100, 255, 218, 0.2);
      border: 2px solid #64ffda;
    }

    .box h3 {
      margin-top: 0;
    }

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

    .box li {
      margin: 5px 0;
    }

    .tips {
      background: rgba(100, 255, 218, 0.1);
      border: 2px solid #64ffda;
      border-radius: 10px;
      padding: 20px;
    }

    .tips ul {
      list-style: none;
      padding: 0;
    }

    .tips li {
      padding: 10px 0;
      border-bottom: 1px solid rgba(100, 255, 218, 0.2);
    }

    .tips li:last-child {
      border-bottom: none;
    }
  `]
})
export class AppComponent {
  storeCode = `// samurai.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';

interface SamuraiState {
  samuraiList: Samurai[];
  selectedId: number | null;
  loading: boolean;
}

const initialState: SamuraiState = {
  samuraiList: [],
  selectedId: null,
  loading: false
};

export const SamuraiStore = signalStore(
  // Stan początkowy
  withState(initialState),

  // Selektory (computed)
  withComputed((store) => ({
    selectedSamurai: computed(() => {
      const id = store.selectedId();
      return store.samuraiList().find(s => s.id === id) ?? null;
    }),
    totalCount: computed(() => store.samuraiList().length),
    totalGold: computed(() =>
      store.samuraiList().reduce((sum, s) => sum + s.gold, 0)
    )
  })),

  // Metody (akcje)
  withMethods((store) => ({
    selectSamurai(id: number): void {
      patchState(store, { selectedId: id });
    },

    addSamurai(samurai: Samurai): void {
      patchState(store, {
        samuraiList: [...store.samuraiList(), samurai]
      });
    },

    removeSamurai(id: number): void {
      patchState(store, {
        samuraiList: store.samuraiList().filter(s => s.id !== id),
        selectedId: store.selectedId() === id ? null : store.selectedId()
      });
    }
  }))
);`;

  componentCode = `// samurai-list.component.ts
import { Component, inject } from '@angular/core';
import { SamuraiStore } from './samurai.store';

@Component({
  selector: 'app-samurai-list',
  standalone: true,
  providers: [SamuraiStore], // Lub w app.config.ts dla globalnego stanu
  template: \`
    <div>
      <p>Liczba samurajów: {{ store.totalCount() }}</p>
      <p>Suma złota: {{ store.totalGold() }}</p>

      @for (samurai of store.samuraiList(); track samurai.id) {
        <div (click)="store.selectSamurai(samurai.id)">
          {{ samurai.name }} - {{ samurai.gold }} złota
        </div>
      }

      @if (store.selectedSamurai(); as selected) {
        <div class="detail">
          Wybrany: {{ selected.name }}
        </div>
      }
    </div>
  \`
})
export class SamuraiListComponent {
  store = inject(SamuraiStore);
}`;

  effectsCode = `// samurai.store.ts - z efektami HTTP
import { signalStore, withState, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap } from 'rxjs';

export const SamuraiStore = signalStore(
  withState(initialState),

  withMethods((store, http = inject(HttpClient)) => ({
    // Metoda z RxJS
    loadSamurai: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() => http.get<Samurai[]>('/api/samurai')),
        tap(samuraiList => patchState(store, {
          samuraiList,
          loading: false
        }))
      )
    ),

    // Metoda async/await
    async loadSamuraiAsync(): Promise<void> {
      patchState(store, { loading: true });

      const response = await fetch('/api/samurai');
      const samuraiList = await response.json();

      patchState(store, { samuraiList, loading: false });
    }
  }))
);`;

  configCode = `// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { SamuraiStore } from './stores/samurai.store';

export const appConfig: ApplicationConfig = {
  providers: [
    // Globalny store - dostępny w całej aplikacji
    SamuraiStore,

    // Lub z feature flags
    provideStore(),
    provideState({ name: 'samurai', reducer: samuraiReducer }),
    provideEffects(SamuraiEffects)
  ]
};`;
}

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. Do czego służy funkcja patchState() w NgRx SignalStore?

  2. 2. Co definiuje funkcja withState() w signalStore()?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż wywołanie signalStore z jego głównymi blokami w typowej kolejności zapisu (jedna linia, od lewej do prawej):

  • Edytor kodu

    Uzupełnij SignalStore dla zarządzania samurajami.

  • Układanie w poziomie

    Ułóż komendę instalacji biblioteki NgRx SignalStore (jedna linia, od lewej do prawej):

  • Klikanie w kolejności

    Kliknij elementy w kolejności, aby zbudować pole wstrzykujące SignalStore w komponencie (modern Angular):

Przydatne artykuły