State-Management mit NGRX

In diesem Tutorial lernen Sie alles über NGRX in Angular: Actions, Reducer, Selektoren und Effects sowie Debugging und Meta-Reducer.

Zur Verbesserung der Benutzerfreundlichkeit halten Angular Anwendungen häufig Zustände lokal vor. Der Benutzer muss also nicht die selben Daten mehrfach vom Server laden.

Damit das nicht in einem Chaos mündet, wurden in den letzten Jahren einige Ansätze zur lokalen Zustandsverwaltung entwickelt. Der populärste ist wohl Redux, das aus der Welt von React stammt. Die populärste Redux-Implementierung für Angular ist @ngrx/store.

In diesem Artikel zeigen wir Ihnen anhand eines Beispiels alles, was Sie über @ngrx/store wissen müssen. Sie lernen, Actions, Reducer, Selektoren und Effects zu nutzen und wiederkehrenden Quellcode mit den NGRX Schematics zu generieren. Das dazu verwendete Quellcode-Beispiel findest Du in unserem GitHub-Account.

Zustandsverwaltung mit Services

Zur Verwaltung von Zuständen gibt uns Angular Services an die Hand. Da sie Singletons in ihrem Wertebereich sind, können sie Daten zentral vorhalten und als Drehscheibe für einzelne Komponenten dienen. Solange die Interaktion mit ihnen wie nachfolgend aussieht, ist auch alles wunderbar:

Komponenten und Services

Die Erfahrung hat jedoch gezeigt, dass selbst bei kleineren Anwendungen die Daten eines Service an vielen Stellen der Anwendung benötigt werden. Somit ergibt sich eine verworrene Struktur mit schwer nachvollziehbaren Datenflüssen:

Verworrene Struktur

Besonders undurchsichtig wird es, wenn sich Services und Komponenten gegenseitig benachrichtigen, um Daten weiterzugeben. Schnell ergeben sich Zyklen, die die Performance negativ beeinflussen und im schlimmsten Fall die Anwendung einfrieren. Da jeder Service seinen eigenen Ausschnitt des Anwendungszustands hat, kommt es auch zu Redundanzen, die auseinanderlaufen und zu Inkonsistenzen führen.

Im Fehlerfall kann sich dann niemand mehr erklären, wie es der Benutzer geschafft hat, den jeweiligen Anwendungszustand herbeizuführen. Genau deswegen ist in den letzten Jahren in der React-Community das Redux-Muster entstanden, das nachfolgend anhand der Angular-Implementierung @ngrx/store näher betrachtet wird.

Was ist NGRX und Redux?

Das Muster Redux sieht vor, dass der gesamte Anwendungszustand von einem globalen Store verwaltet wird. Diesen könnte man mit einer einfachen In-Memory-Datenbank im Browser vergleichen:

Redux

Der Store verwaltet den Anwendungszustand in einem Objektbaum. In erster Näherung könnte man sich die Wurzel als Datenbankschema und die Knoten in der Ebene darunter als Tabellen vorstellen. Dieser Zustandsbaum Zustandsbaum (NGRX) ist unveränderbar (immutable), was bedeutet, dass bei jeder Änderung die betroffenen Objekte zu tauschen sind. Somit kann der Store durch Vergleich der Objektreferenzen herausfinden, welche Knoten sich geändert haben, ohne sämtliche Eigenschaften zu überwachen.

Das führt zu einer besseren Performance bei der Änderungsverfolgung. Da beim Lesen nichts kaputtgehen kann, gewährt Redux jedem Systembestandteil einen direkten Lesezugriff. Dazu bietet die hier betrachtete Implementierung @ngrx/store Observables an, die den Interessenten bei Datenänderungen informieren.

Direkt schreiben dürfen die einzelnen Komponenten jedoch nicht. Hier wäre die Gefahr von Inkonsistenzen und Redundanzen zu groß. Stattdessen senden sie lediglich Actions an den Store. In erster Näherung könnte man sich eine Action wie den Aufruf einer Stored Procedure vorstellen. Sie beinhaltet einen Typ, den man mit dem Namen der Stored Procedure vergleichen könnte, und eine Payload mit Parametern.

Zum Abarbeiten der Actions finden sich im Store sogenannte Reducer. Dabei handelt es sich um Funktionen, die für einen bestimmten Ast des Zustandsbaums verantwortlich sind. Um Zyklen zu vermeiden, erhält jeder Reducer jede vom Store empfangene Action und kann mit den darin zu findenden Informationen seinen Teil des Zustandsbaums auf den neuesten Stand bringen. Natürlich wird nicht jeder Reducer auf alle Actions reagieren, sondern zunächst mal ermitteln, ob die aktuelle Action für ihn überhaupt relevant ist.

Reducer laufen jedoch immer synchron ab. Für asynchrone Nebeneffekte kommt das Schwesterprojekt @ngrx/effects zum Einsatz. Actions können damit außerhalb des Stores asynchrone Operationen – sogenannte Effects – anstoßen. Sobald diese fertig sind, senden sie eine weitere Action an den Store. Damit signalisieren sie den Erfolg samt Ergebnis oder einen Fehlerzustand.

NGRX einrichten

Um die Umsetzung des Redux-Musters mit @ngrx/store zu veranschaulichen, fügen wir zunächst die Bibliothek unserer Anwendung hinzu:

ng add @ngrx/store

Diese Anweisung lädt das Paket @ngrx/store herunter und importiert sein StoreModule StoreModule in das AppModule der Anwendung. Hierzu kommt, wie auch schon vom Router bekannt, seine statische forRoot-Methode zum Einsatz:

imports: [
    [...]
    StoreModule.forRoot({}, {}),
]

Die beiden Argumente erlauben das Registrieren von Reducern und sogenannten Meta-Reducern. Letzteres sind Reducer, die alle anderen Reducer erweitern. Damit lassen sich zum Beispiel sämtliche Zustandsänderungen protokollieren. Wir lassen diese beiden Argumente fürs erste Mal außen vor, unter anderem weil wir unsere Reducer in ein paar Momenten auf der Ebene des Feature-Module FlightBookingModule einrichten werden.

Das oben erwähnte Schwesterprojekt @ngrx/effects lässt sich auf die gleiche Weise hinzufügen:

ng add @ngrx/effects

Ähnlich wie zuvor importiert diese Anweisung das EffectsModule ins AppModule der Anwendung:

imports: [
    [...]
    EffectsModule.forRoot([]),
],

Auch hier ignorieren wir vorerst den übergebenen Parameter. Wir werden uns weiter unten im Rahmen des FlightBookingModule um das Thema Effects kümmern.

Um uns die Arbeit mit NGRX zu vereinfachen, installieren wir uns die Bibliothek @ngrx/schematics:

npm i @ngrx/schematics

Diese Bibliothek beinhaltet Baupläne (Schematics), die es der CLI ermöglichen, NGRX-spezifischen Code zu generieren. Um z. B. NGRX-Unterstützung zum FlightBookingModule hinzuzufügen, genügt der folgende Aufruf:

ng g @ngrx/schematics:feature flight-booking/+state/flight-booking --module flight-booking\flight-booking.module.ts --creators --api false

Dieser Aufruf importiert StoreModule und EffectsModule in das FlightBookingModule und generiert die diskutierten Buildings-Blocks, wie z. B. Reducer oder Actions samt Testfälle:

Store-Unterstützung für ein Feature-Module generieren

Das erste übergebene Argument legt fest, dass die Building-Blocks im Ordner flight-booking/+state zu generieren sind und das Präfix flight-booking erhalten. Das Plus (+) im Ordnernamen +state hat übrigens keine besondere Bedeutung. Es handelt sich vielmehr um eine übliche Konvention, die bewirkt, dass dieser Ordner in einer sortierten Ansicht als Erster erscheint.

Der Schalter --creators legt fest, dass die 2019 eingeführte Creators-API verwendet wird. Da sich diese mittlerweile zum Standard unter NGRX entwickelt hat, gehen wir hier ausschließlich darauf ein. Mit --api false legen wir fest, dass die CLI keine zusätzlichen Building-Blocks für die Kommunikation mit dem Backend generieren soll. Das übernehmen wir weiter unten, wenn wir genauer auf das Effects-Modul eingehen, selbst.

Neben diesen Dateien hat die CLI auch unser FlightBookingModule erweitert. Es importiert nun das StoreModule sowie das EffectsModule:

// src/app/flight-booking/flight-booking.module.ts

[...]

import { StoreModule } from '@ngrx/store';
import * as fromFlightBooking from './+state/flight-booking.reducer';
import { EffectsModule } from '@ngrx/effects';
import { FlightBookingEffects } from './+state/flight-booking.effects';

@NgModule({
  imports: [
    [...]
    StoreModule.forFeature(fromFlightBooking.flightBookingFeatureKey, fromFlightBooking.reducer),
    EffectsModule.forFeature([FlightBookingEffects])
  ],
  ...
})
export class FlightBookingModule { }

Da es sich hier um ein Feature-Module handelt, kommt für den Import des StoreModule und des EffectsModule die Methode forFeature zum Einsatz. Analog zur forChild-Methode des Routers verhindert forFeature, dass die Anwendung zentrale Services in Feature-Modules erneut einrichtet und somit dupliziert oder überschreibt.

Diese Aufrufe konfigurieren die beiden Module auch mit von der CLI generierten Konstrukten:

  • fromFlightBooking.flightBookingFeatureKey: Konstante mit dem Namen des Zweigs, der im Zustandsbaum unser Feature-Module repräsentiert. Sie hat den Wert flightBooking.

  • fromFlightBooking.reducer: Grundgerüst eines Reducers, den wir gleich für unsere Zwecke anpassen werden.

  • FlightBookingEffects: Grundgerüst eines Effects, den wir gleich für unsere Zwecke anpassen werden.

Building-Blocks implementieren

Nachdem Nx für die einzelnen Building-Blocks Dateien angelegt hat, gilt es, diese mit Leben zu erfüllen.

State modellieren

Als Erstes stellt man sich die Frage, wie sich der Zustand des Feature-Module gestaltet. Da wir Flüge laden wollen, liegt es nahe, im Zustand ein Array dafür vorzusehen. Außerdem möchten wir wissen, ob gerade Flüge geladen werden und ob gerade ein Fehler vorliegt.

Hierfür sehen die generierten Building-Blocks ein Interface State vor:

// src/app/flight-booking/+state/flight-booking.reducer.ts

import { Action, createReducer, on } from '@ngrx/store';

// Hinzufügen:
import { Flight } from '../flight';
import * as FlightBookingActions from './flight-booking.actions';

export const flightBookingFeatureKey = 'flightBooking';

export interface State {
  // Hinzufügen:
  flights: Flight[];
  loading: boolean;
  error: unknown;
}

export const initialState: State = {
  // Hinzufügen:
  flights: [],
  loading: false,
  error: {}
};

[...]

Die Konstante initialState bekommt Standardwerte für die Einträge im State-Interface. Damit verhindert man die Werte null bzw. undefined, die üblicherweise zusätzliche Prüfungen notwendig machen.

Im Übrigen sind wir der Meinung, dass der Interface-Name zu allgemein ist. Deswegen benennen wir dieses Interface in FlightBookingState um:

// src/app/flight-booking/+state/flight-booking.reducer.ts

[...]

// Von State in FlightBookingState umbenennen:
export interface FlightBookingState {
  flights: Flight[];
  loading: boolean;
  error: string;
}

[...]

Das Umbenennen sollten Sie mit den Refactoring-Möglichkeiten Ihrer IDE durchführen, damit sie die Änderung in allen anderen Dateien nachzieht. In Visual Studio Code markieren Sie dazu den alten Namen und drücken dann F2.

Eine zentrale Idee von Redux ist, dass sich der gesamte Zustand in einem einzigen Zustandsbaum befindet. Für diesen Zustandsbaum benötigen wir eine Wurzel, die auf den FlightBookingState sowie auf die Zustandsobjekte der anderen Module verweist.

Dieser Knoten hat also pro Feature-Module mindestens eine Eigenschaft. Um zu verhindern, dass man die Übersicht verliert, ist es üblich, pro Feature-Module eine eigene Sicht auf diesen sogenannten AppState einzurichten. Eine solche Sicht lässt sich durch ein Interface, das sich auf die im Feature-Module benötigen Eigenschaften beschränkt, ausdrücken.

// src/app/flight-booking/+state/flight-booking.reducer.ts

[...]

export const flightBookingFeatureKey = 'flightBooking';

// Hinzufügen:
export interface FlightBookingAppState {
  flightBooking: FlightBookingState;
}

// Nicht verändern
// (und nicht mit dem FlightBookingAppState verwechseln):
export interface FlightBookingState {
  flights: Flight[];
  loading: boolean;
  error: string;
}

Auch wenn dieses Interface unsere Anforderungen erfüllt, gibt es hier eine kleine Unschönheit: Der Name flightBooking befindet sich sowohl in der Konstanten flightBookingFeatureKey als auch im neuen Interface FlightBookingAppState. Die Konstante kommt, wie weiter oben besprochen, beim Registrieren des StoreModule zum Einsatz. Sie beinhaltet genauso wie das neue Interface den Namen jenes Zweigs im Zustandsbaum, den unser FlightBookingModule verwendet.

Bei einer Namensänderung müssen wir also immer diese beiden Stellen anpassen. Zum Glück gibt uns TypeScript die Möglichkeit, eine Eigenschaft nach einer Konstanten zu benennen:

// src/app/flight-booking/+state/flight-booking.reducer.ts

[...]

export const flightBookingFeatureKey = 'flightBooking';

export interface FlightBookingAppState {
  // Verweis auf Konstante in eckigen Klammern:
  [flightBookingFeatureKey]: FlightBookingState;
}

export interface FlightBookingState {
  flights: Flight[];
  loading: boolean;
  error: string;
}

Actions festlegen

Als Nächstes stellt man sich die Frage, welche Aktionen mit dem State auszuführen sind. Alle diese Aktionen werden in der Datei flight-booking.action.ts eingerichtet. Auch hier können Sie die von der CLI generierten Konstrukte mittels Refactoring anpassen:

// src/app/flight-booking/+state/flight-booking.actions.ts

import { createAction, props } from '@ngrx/store';
import { Flight } from '../flight';

export const loadFlights = createAction(
  '[FlightBooking] loadFlights',
  props<{from: string; to: string}>()
);

export const flightsLoaded = createAction(
  '[FlightBooking] flightsLoaded',
  props<{flights: Flight[]}>()
);

export const loadFlightsError = createAction(
  '[FlightBooking] loadFlightsError',
  props<{error: string}>()
);

Die zugewiesenen Namen müssen eindeutig sein, da NGRX die Aktionen daran unterscheiden kann. Es gehört auch zum guten Ton, dem Namen eine Kategorie – hier [FlightBooking] – voranzustellen. Diese Kategorie soll beim Debuggen über die Herkunft der Action, z. B. das Modul, aus dem sie stammt, informieren.

Gerade asynchrone Operationen wie das Laden von Flügen gilt es, durch mehrere Actions zu repräsentieren. Beispielsweise fordert loadFlights das Laden von Flügen an, während flightsLoaded anzeigt, dass dieser Vorgang erfolgreich war, und dem Store die ermittelten Flüge übergibt. Mit loadFlightsError weist die Anwendung hingegen auf einen Fehler beim Laden hin.

Reducer definieren

Der Reducer, der sich um das Abarbeiten der Actions kümmert, findet sich in der Datei flight-booking.reducer.ts.

// src/app/flight-booking/+state/flight-booking.reducer.ts

[...]

export const reducer = createReducer(
  initialState,

  on(FlightBookingActions.flightsLoaded, (state, action) => {
    const flights = action.flights;
    const loading = false;
    return {...state, flights, loading};
  }),

  on(FlightBookingActions.loadFlights, (state, action) => {
    const flights = [] as Flight[];
    const loading = false;
    return {...state, flights, loading};
  }),

  on(FlightBookingActions.loadFlightsError, (state, action) => {
    const flights = [] as Flight[];
    const loading = false;
    const error = action.error;
    return {...state, flights, loading, error};
  }),
);

Jeder im Reducer mit on eingerichtete Handler nimmt den aktuellen State sowie die aktuelle Action entgegen und liefert einen neuen State zurück. Wichtig ist, dass der State nicht verändert werden darf – bei Redux ist er per definitionem immutable. Stattdessen erzeugt der Reducer jeweils ein neues State-Objekt. Dazu klont er mit dem Spread-Operator das alte Objekt und tauscht dabei die gewünschten Eigenschaften aus.

Beim Handler für loadFlights fällt auf, dass lediglich das Array mit dem Suchergebnis zurückgesetzt wird. Da Reducer immer synchron arbeiten, findet das asynchrone Laden außerhalb des Stores in einem Effect statt.

Lerne Angular Architektur

Lerne mehr über die Strukturierung Deiner Angular-Anwendung und Architektur mit unserem freien eBook.

free ebook

Du kannst es jetzt hier herunterladen!

Effect einrichten

Der Effect für das Laden von Flügen kommt in die generierte Datei flight-booking.effects.ts:

// src/app/flight-booking/+state/flight-booking.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';

import { catchError, map, switchMap } from 'rxjs/operators';
import { FlightService } from '../flight.service';
import { flightsLoaded, loadFlights, loadFlightsError } from './flight-booking.actions';

@Injectable()
export class FlightBookingEffects {

  flightsLoad$ = createEffect(() => this.actions$.pipe(
          ofType(loadFlights),
          switchMap(a => this.flightService.find(a.from, a.to).pipe(
            map(flights => flightsLoaded({flights})),
            catchError(error => of(loadFlightsError({error})))
          )),
  ));

  constructor(
    private flightService: FlightService,
    private actions$: Actions) {}

}

Da der Effect technisch gesehen ein Service ist, kann er sich andere Services injizieren lassen. Auf diese Weise erhält er unseren FlightService sowie ein Actions-Objekt. Dabei handelt es sich um ein Observable, das sämtliche Aktionen, die auch an den Store gesendet werden, empfängt. Daraus gilt es nun weitere Observables, die sich um die Ausführung der Effects kümmern, abzuleiten.

Da wir uns hier nur für loadFlights interessieren, filtert der Effect mit ofType die empfangenen Actions. Der Operator switchMap delegiert an den FlightService. Der Einsatz von switchMap ist hier notwendig, weil find ein neues Observable liefert, auf das zu wechseln ist. Dieses transportiert ein Flight-Array, das map in einer flightsLoaded-Action verpackt. Diese Action sendet @ngrx/effects an den Store, wo sich der gezeigte Reducer darum kümmert.

Im Fehlerfall bildet catchError den erhaltenen Fehler auf eine loadFlightsError-Action ab. Auch dieser wird von unserem Reducer im Store verarbeitet.

Mit Effect auf Store zugreifen

Es gibt Fälle, in denen ein Effect auf den Store zugreifen muss. Stellen wir uns dazu vor, wir wollen alle Flüge aus dem Store speichern. Dazu sehen wir die folgenden Actions vor:

export const saveAllFlights = createAction(
  '[FlightBooking] saveAll'
);

export const allFlightsSaved = createAction(
  '[FlightBooking] allFlightsSaved'
);

export const saveAllFlightsError = createAction(
  '[FlightBooking] saveAllFlightsError',
  props<{error: unknown}>()
);

Der Effect könnte dann so aussehen:

// src/app/flight-booking/+state/flight-booking.effects.ts

import { Injectable } from '@angular/core';
import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';

import { catchError, map, switchMap } from 'rxjs/operators';
import { FlightService } from '../flight.service';
import { allFlightsSaved, saveAllFlights, saveAllFlightsError } from './flight-booking.actions';
import { FlightBookingAppState, flightBookingFeatureKey } from './flight-booking.reducer';

@Injectable()
export class FlightBookingEffects {

  [...]

  allFlightsSave$ = createEffect(() => this.actions$.pipe(
    ofType(saveAllFlights),
    concatLatestFrom(() => this.store.select(state => state[flightBookingFeatureKey].flights)),
    switchMap( ([action, flightsFromStore]) => this.flightService.saveAll(flightsFromStore).pipe(
      map(() => allFlightsSaved()),
      catchError(error => of(saveAllFlightsError({error})))
    )),
  ));

  constructor(
    private flightService: FlightService,
    private actions$: Actions,
    private store: Store<FlightBookingAppState>
    ) {}

}

Interessant ist hier der Operator concatLatestFrom, den @ngrx/effects bereitstellt. Er kombiniert die aktuelle Action um weitere abgefragte Informationen. Hier handelt es sich um die Flüge aus dem Store, den sich der Effect nun in den Konstruktor injizieren lässt. Das von switchMap entgegengenommene Ergebnis ist ein Tupel bestehend aus der Action und dem Flug-Array.

Hinweis: Es spricht auch nichts dagegen, zu speichernde Objekte über die Action an den Effect (und Reducer) weiterzureichen. Das nachfolgende Beispiel zeigt dazu eine Action zum Speichern eines einzelnen Fluges:

export const saveFlight = createAction(
 '[FlightBooking] loadFlightsError',
 props<{flight: Flight}>()
);

Auf den Store zugreifen

Die gute Nachricht vorweg: Den schwierigen Teil haben wir hinter uns gebracht. Jetzt müssen die einzelnen Komponenten den Store nur noch konsumieren. Zur Demonstration injizieren wir den Store in die FlightSearchComponent:

// src/app/flight-search/flight-search.component.ts

[...]

// Hinzufügen:
import { Store } from '@ngrx/store';
import { loadFlights } from '../+state/flight-booking.actions';
import { FlightBookingAppState, flightBookingFeatureKey } from '../+state/flight-booking.reducer';

@Component({ ... })
export class FlightSearchComponent implements OnInit {

  [...]

  // Hinzufügen bzw. anpassen:
  flights$ = this.store.select(appState => appState[flightBookingFeatureKey].flights);

  [...]

  constructor(
    // Hinzufügen:
    private store: Store,
    private flightService: FlightService) {
  }

  ngOnInit(): void { }

  // Anpassen:
  search(): void {
    this.store.dispatch(loadFlights({from: this.from, to: this.to}));
  }

  [...]
}

Der Store ist mit unserer Sicht auf die Wurzel des Zustandsbaums zu typisieren. Unsere FlightSearchComponent bezieht nun das Observable flights$ mit den geladenen Flügen aus dem Store. Dazu verwendet sie die Methode select.

Um das Laden anzustoßen, versendet die Methode search die Action loadFlights. Hierzu kommt die Methode dispatch des Stores zum Einsatz. Die Methoden select und dispatch sind tatsächlich die einzigen, die wir vom Store benötigen.

Würden wir die gesamte Komponente auf den Store umstellen, könnten wir auch den Verweis auf den FlightService entfernen. Diese Vorgehensweise vereinfacht die einzelnen Komponenten, da diese nur mehr mit dem Store interagieren müssen.

Debuggen mit dem Store

Das Chrome-Plug-in Redux DevTools hilft erheblich beim Debuggen von Lösungen, die auf NGRX basieren. Unter anderem zeigt es den aktuellen Inhalt des Stores und die verwendeten Actions. Um unsere Lösung damit zu verbinden, benötigen wir das Paket @ngrx/store-devtools

npm install @ngrx/store-devtools

Danach müssen Sie gegebenenfalls Ihre IDE, z. B. Visual Studio Code, neu starten. Um dieses Paket zu nutzen, ist das StoreDevtoolsModule ins AppModule zu importieren:

// src/app/app.module.ts

[...]

// Hinzufügen:
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from 'src/environments/environment';

@NgModule({
    imports: [
      [...]

      // Hinzufügen:
      !environment.production ? StoreDevtoolsModule.instrument() : []
    ],
    ...
})
export class AppModule { }

Üblicherweise kommt das StoreDevtoolsModule nur im Debug-Modus zum Einsatz. Im Produktionsmodus wäre der dadurch verursachte Overhead zu groß.

Wichtig

Starten Sie nach dem Installieren der Redux DevTools Ihren Browser neu.

Wenn Sie nun Ihre Anwendung starten, finden Sie in den Chrome DevTools (F12) ein Registerblatt Redux:

Redux DevTools im Browser

Hier können Sie die einzelnen Actions sowie die dadurch ausgelösten Änderungen am Zustandsbaum und den aktuellen Anwendungszustand einsehen. Außerdem können Sie unter anderem mit dem Schieberegler am unteren Ende in der Zeit zurückgehen, um einen früheren Anwendungszustand herbeizuführen. Sie können auch die einzelnen Zustandsänderungen noch mal abspielen, um sie besser nachzuvollziehen.

Selektoren

In der zuvor beschriebenen Umsetzung greift die FlightSearchComponent direkt auf den Store zu:

flights$ = this.store.select(appState => appState[flightBookingFeatureKey].flights);

Das ist zwar einfach, hat aber auch den Nachteil, dass die Komponente an den Aufbau des Stores gebunden wird. Dies erschwert ein nachträgliches Refactoring. Selektoren lösen das Problem, indem sie die nötigen Zugriffspfade an zentrale Stellen auslagern. Außerdem können Selektoren eventuelle Transformationen, die sie auf abgerufene Daten durchführen, cachen. Das verbessert die Performance.

Ein erster Selektor

Um den Einsatz von Selektoren zu demonstrieren, erweitern wir zunächst den FlightBookingState um eine Eigenschaft hide:

// src/app/flight-booking/+state/flight-booking.reducer.ts

[...]

export interface FlightBookingState {
  flights: Flight[];
  loading: boolean;
  error: unknown;
  // Hinzufügen:
  hide: number[];
}

export const initialState: FlightBookingState = {
  flights: [],
  loading: false,
  error: {},
  // Hinzufügen:
  hide: [4]
};

Diese Eigenschaft soll die IDs von Flügen aufnehmen, die die Anwendung nicht anzeigen soll. Damit die Komponenten weder diese Logik noch den Aufbau des Zustandsbaums kennen müssen, richten wir dafür einen Selektor ein. Die CLI hat dafür bereits die Datei flight-booking.selectors.ts eingerichtet, die sich für unsere Zwecke erweitern lässt:

// src/app/flight-booking/+state/flight-booking.selectors.ts

[...]

// Hinzufügen:
import { createSelector } from '@ngrx/store';
import { flightBookingFeatureKey } from './flight-booking.reducer';

[...]

// Hinzufügen:
export const selectFlights = createSelector(
  (appState: FlightBookingAppState) => appState[flightBookingFeatureKey].flights,
  (appState: FlightBookingAppState) => appState[flightBookingFeatureKey].hide,
  (flights, hide) => flights.filter(f => !hide.includes(f.id))
);

Die ersten beiden an createSelector übergebenen Lambda-Ausdrücke rufen zunächst die Eigenschaften flights und hide aus dem Zustandsbaum ab. Diese Werte übergibt createSelector an den letzten Lambda-Ausdruck. Dabei handelt es sich um einen sogenannten Projector, der die abgerufenen Werte auf das gewünschte Ergebnis abbildet. Diesen Wert cacht der Selektor so lange, bis sich die zugrunde liegenden Werte ändern.

Hinweis: Von createSelector existieren übrigens mehrere Überladungen, sodass sie bis zu acht Lambda-Ausdrücke, die Werte aus dem Zustandsbaum abrufen, übergeben können.

Dieser Selektor lässt sich jetzt in der FlightSearchComponent nutzen:

// src/app/flight-search/flight-search.component.ts
[...]

// Hinzufügen:
import { selectFlights } from '../+state/flight-booking.selectors';

[...]

export class FlightSearchComponent implements OnInit {

    [...]

    // Aktualisieren:
    flights$ = this.store.select(selectFlights);

    [...]
}

Selektoren verschachteln

Um Wiederholungen zu vermeiden, können Selektoren auch Werte anderer Selektoren nutzen:

// src/app/flight-booking/+state/flight-booking.selectors.ts

[...]

// Auf Knoten flightBooking (= Wert von fromFlightBooking.flightBookingFeatureKey) zugreifen:
export const selectFlightBookingState = createFeatureSelector(
  fromFlightBooking.flightBookingFeatureKey
);

export const selectAllFlights = createSelector(
  selectFlightBookingState,
  fbs => fbs.flights
);

export const selectFlightsToHide = createSelector(
  selectFlightBookingState,
  fbs => fbs.hide
);

export const selectFilteredFlights = createSelector(
  selectAllFlights,
  selectFlightsToHide,
  (flights, hide) => flights.filter(f => !hide.includes(f.id))
);

In diesem Beispiel kommt auch ein sogenannter Feature-Selektor Feature-Selektor zum Einsatz. Dabei handelt es sich um einen Selektor, der die Wurzel des Zustandsbaums auf eine ihrer Eigenschaften abbildet. Der Name dieser Eigenschaft ist als String zu übergeben. Das gezeigte Beispiel übergibt beispielsweise die Konstante fromFlightBooking.flightBookingFeatureKey mit dem Wert flightBooking. Deswegen fördert dieser Feature-Selektor den FlightBookingState zutage.

Selektoren mit Parameter

Um Parameter an einen Selektor zu übergeben, müssen Sie ihn in einer Funktion verpacken. Diese Funktion nimmt die Parameter entgegen und liefert den parametrisierten Selektor zurück:

// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function selectFlightsWithParams(exclude: number[]) {
  return createSelector(
    (appState: FlightBookingAppState) => appState[flightBookingFeatureKey].flights,
    (appState: FlightBookingAppState) => appState[flightBookingFeatureKey].hide,
    (flights, hide) => flights.filter(f => !hide.includes(f.id) && !exclude.includes(f.id))
  );
}

Die Funktion selectFlightsWithParams nimmt ein exclude-Array mit Flug-IDs entgegen. Die damit referenzierten Flüge schließt der Selektor aus der Ergebnismenge aus.

Um mit diesem Selektor Daten abzurufen, müssen Sie nun lediglich selectFlightsWithParams aufrufen. Der so erhaltene Selektor liefert nun gemeinsam mit dem Store die gewünschten Einträge:

flights$ = this.store.select(selectFlightsWithParams([5]));

Meta-Reducer

Logiken, die Sie in vielen oder allen Reducern stattfinden lassen möchten, können Sie in sogenannte Meta-Reducer Meta-Reducer auslagern. Das sind Reducer, die die herkömmlichen Reducer ummanteln. In der Regel delegieren sie an die herkömmlichen Reducer. Davor und danach können sie jedoch eigene Logiken anstoßen.

Das nachfolgende Beispiel protokolliert mit einem Meta-Reducer sämtliche Zustände und Actions:

// src/app/+state/meta.reducer.ts

import { ActionReducer } from '@ngrx/store';

export function debug(reducer: ActionReducer): ActionReducer {
    return function(state, action) {

      // Protokollieren:
      console.log('state', state);
      console.log('action', action);

      // An "herkömmlichen" Reducer delegieren:
      return reducer(state, action);
    };
}

Damit die Anwendung Ihre Meta-Reducer ausführt, sind sie beim Aufruf von StoreModule.forRoot im AppModule zu registrieren:

// src/app/app.module.ts

[...]
// Hinzufügen:
import { debug } from './+state/meta.reducer';

@NgModule({
    imports: [
      [...]
      StoreModule.forRoot({}, {
          // Meta-Reducer registrieren:
          metaReducers: [debug]
      }),
      [...]
    ],
    ...
})
export class AppModule { }

Zusammenfassung

NGRX zentralisiert unseren Anwendungszustand und verhindert somit Redundanzen und Inkonsistenzen. Während jeder Systembestandteil den Zustand via Observables lesen darf, erfolgt das Schreiben indirekt durch das Versenden von Actions. Reducer nehmen diese Actions und verändern den Zustand auf wohldefinierte Weise. Selektoren erlauben das Abrufen und Transformieren von Daten und Effects kümmern sich um Seiteneffekte, wie der Kommunikation mit dem Backend.

Nächste Schritte - Mehr Architektur!

NGRX hilft beim Strukturieren großer Anwendungen. Daneben gibt es allerdings noch weitere Fragen zu beantworten:

  • Nach welchen Kriterien kann man Anwendungen in kleinere und besser überschaubare Teile untergliedern?
  • Wie kann man verhindern, dass diese Teile miteinander zu stark verwebt werden?
  • Welche bewährten Muster helfen dabei?
  • Sollen wir eine große strukturierte Anwendung oder Micro Frontends nutzen
  • Wie hilft das neue Module Federation dabei?

Unser freies eBook deckt diese Fragen und mehr ab:

free ebook

Lade es jetzt hier herunter!