State Management with Services

Angular provides services as a tool for managing state. Because services are singletons within their injection scope, they can hold data centrally and act as a hub for individual components. As long as interaction with them resembles the following, everything works well:

Komponenten und Services

Experience has shown, however, that even in smaller applications, the data of a service is needed in many parts of the application. This results in a tangled structure with data flows that are hard to trace:

Verworrene Struktur

Things get especially opaque when services and components notify each other to pass data along. Cycles can quickly emerge, which harm performance and in the worst case freeze the application. Because each service holds its own slice of application state, redundancy also arises, and these redundant copies can drift apart and cause inconsistencies.

In the event of an error, nobody can explain how the user managed to bring about the respective application state. This is precisely why the Redux pattern emerged in the React community in recent years, which we will now examine more closely using the Angular implementation @ngrx/store.

What Are NGRX and Redux?

The Redux pattern dictates that all application state is managed by a global store. One could compare this store to a simple in-memory database in the browser:

Redux

The store manages application state in an object tree. As a rough approximation, one can think of the root as a database schema and the nodes below it as tables. This state tree or NGRX state is immutable, which means that on every change, the affected objects must be replaced. In this way, the store can determine which nodes have changed by comparing object references, without having to observe every property.

This leads to better performance during change detection. Since nothing can break during reads, Redux grants every system component direct read access. To this end, the implementation under consideration, @ngrx/store, offers observables that notify interested parties about data changes.

Individual components, however, cannot write directly. The risk of inconsistencies and redundancies would be too great. Instead, they merely dispatch actions to the store. As a rough analogy, one might think of an action as a call to a stored procedure. An action contains a type, which can be compared to the name of the stored procedure, and a payload with parameters.

To process actions, reducers reside in the store. These are functions that are each responsible for a specific branch of the state tree. To avoid cycles, every reducer receives every action the store receives and can update its part of the state tree based on the information contained within. Of course, not every reducer reacts to every action—rather, it will first determine whether the current action is relevant to it.

Reducers always run synchronously, however. For asynchronous side effects, the companion project @ngrx/effects is used. Actions can, in turn, trigger asynchronous operations—so-called effects—outside the store. Once these complete, they dispatch another action to the store. In doing so, they signal success along with a result, or an error state.

Setting Up NGRX

To illustrate implementing the Redux pattern with @ngrx/store, we begin by adding the library to our application:

ng add @ngrx/store

This command downloads the @ngrx/store package and imports its StoreModule into the application's AppModule. As we already know from the router, its static forRoot method is used here:

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

The two arguments allow registering reducers and meta-reducers. The latter are reducers that extend all other reducers. This enables, for example, logging all state changes. We will leave both of these arguments aside for the moment, partly because we will soon set up our reducers at the level of the feature module FlightBookingModule.

The companion project @ngrx/effects mentioned above can be added in the same way:

ng add @ngrx/effects

Similar to before, this command imports the EffectsModule into the application's AppModule:

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

Here too, we will ignore the passed parameter for now. We will address effects later on within the scope of the FlightBookingModule.

To simplify our work with NGRX, we will also install the library @ngrx/schematics:

npm i @ngrx/schematics

This library contains schematics that enable the CLI to generate NGRX-specific code. For instance, to add NGRX support to the FlightBookingModule, the following invocation suffices:

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

This call imports StoreModule and EffectsModule into the FlightBookingModule and generates the building blocks discussed earlier, such as reducers or actions along with test cases:

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

The first argument passed specifies that the building blocks are to be generated in the folder flight-booking/+state and receive the prefix flight-booking. The plus sign (+) in the folder name +state has no special significance. Instead, it is a common convention that causes this folder to appear first in a sorted view.

The switch --creators specifies that the Creators API introduced in 2019 is used. Since this has since become the standard in NGRX, we will exclusively rely on it here. With --api false, we specify that the CLI should not generate additional building blocks for backend communication. We will handle that ourselves further below when we take a closer look at the effects module.

In addition to these files, the CLI has also extended our FlightBookingModule. It now imports the StoreModule as well as the 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 { }

Since this is a feature module, the forFeature method is used when importing the StoreModule and the EffectsModule. Analogous to the router's forChild method, forFeature prevents the application from reinitializing central services in feature modules, which might otherwise lead to duplication or overwriting.

These calls also configure both modules with structures generated by the CLI:

  • fromFlightBooking.flightBookingFeatureKey: A constant containing the name of the branch that represents our feature module's spot in the state tree. Its value is flightBooking.

  • fromFlightBooking.reducer: A reducer skeleton that we will soon tailor to our needs.

  • FlightBookingEffects: An effect skeleton that we will adjust to fit our needs.

Building Blocks in die Praxis umsetzen

Sobald Nx die Dateien für die einzelnen Building-Blocks generiert hat, beginnt die eigentliche Arbeit: Diese müssen nun mit sinnvoller Logik befüllt werden.

Den State strukturieren

Zunächst stellt sich die grundlegende Frage nach der Struktur des Zustands für das Feature-Module. Da das Ziel darin besteht, Flüge zu laden, bietet es sich an, im Zustand ein Array dafür vorzusehen. Hinzu kommt die Notwendigkeit, festzuhalten, ob Flüge gerade geladen werden und ob dabei ein Fehler aufgetreten ist.

Die generierten Building-Blocks definieren hierfür ein Interface mit der Bezeichnung State:

// 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 weist den Einträgen des State-Interfaces ihre Standardwerte zu. Dies verhindert das Auftreten von null beziehungsweise undefined, die üblicherweise zusätzliche Prüfungen nach sich ziehen.

Unserer Ansicht nach ist der Interface-Name State zu generisch gehalten. Daher benennen wir das 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;
}

[...]

Die Umbenennung sollten Sie über die Refactoring-Funktionen Ihrer IDE durchführen, damit die Änderung in sämtlichen abhängigen Dateien erfasst wird. In Visual Studio Code markieren Sie den alten Namen und drücken die Taste F2.

Ein zentrales Konzept von Redux besagt, dass der gesamte Zustand in einem einzigen Zustandsbaum abgelegt wird. Für diesen Baum braucht es eine Wurzel, die neben dem FlightBookingState auch die Zustandsobjekte der anderen Module referenziert.

Diese Wurzel enthält also mindestens eine Eigenschaft pro Feature-Module. Zur Wahrung der Übersichtlichkeit ist es gängige Praxis, für jedes Feature-Module eine eigene Sicht auf den sogenannten AppState zu definieren. Eine solche Sicht lässt sich als Interface formulieren, das die für das Feature-Module relevanten Eigenschaften gezielt herausgreift.

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

Obwohl dieses Interface unseren Anforderungen genügt, gibt es eine kleine Unschönheit: Der Name flightBooking taucht sowohl in der Konstanten flightBookingFeatureKey als auch im neuen Interface FlightBookingAppState auf. Die Konstante wird – wie weiter oben erläutert – bei der Registrierung des StoreModule verwendet. Sie enthält ebenso wie das neue Interface den Namen des Zweigs im Zustandsbaum, den unser FlightBookingModule beansprucht.

Bei einer Umbenennung müssen daher stets beide Stellen berücksichtigt werden. TypeScript bietet jedoch die Möglichkeit, eine Eigenschaft anhand 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 definieren

Im nächsten Schritt ist zu klären, welche Aktionen auf den State angewendet werden sollen. Diese Aktionen werden sämtlich in der Datei flight-booking.action.ts eingerichtet. Auch hier lassen sich die von der CLI generierten Bausteine per 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 Bezeichnungen müssen zwingend eindeutig sein, da NGRX anhand dieser Namen zwischen den Aktionen unterscheidet. Üblich ist außerdem, dem Namen eine Kategorie – hier [FlightBooking] – voranzustellen. Diese Kategorie dient beim Debuggen dazu, die Herkunft der Action, etwa das Modul, aus dem sie stammt, nachvollziehen zu können.

Insbesondere asynchrone Operationen wie das Laden von Flügen sollten durch mehrere Actions repräsentiert werden. So stößt loadFlights das Laden der Flüge an, während flightsLoaded den erfolgreichen Abschluss des Vorgangs signalisiert und die ermittelten Flüge an den Store übergibt. Für den Fall eines Ladefehlers steht loadFlightsError bereit.

Reducer ausgestalten

Der Reducer, der für die Abarbeitung der Actions zuständig ist, befindet 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 Handler, der im Reducer über on verdrahtet ist, erhält den aktuellen State sowie die anstehende Action und liefert einen neuen State zurück. Wichtig ist, dass der State dabei nicht modifiziert werden darf – in Redux ist er per Definition immutable. Stattdessen erzeugt der Reducer stets ein neues State-Objekt. Dazu klont er das bisherige Objekt mithilfe des Spread-Operators und ersetzt die relevanten Eigenschaften.

Beim Handler für loadFlights ist zu beachten, 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.

Angular-Architektur lernen

Erweitere Dein Wissen über den Aufbau von Angular-Anwendungen und Architektur mit unserem kostenlosen eBook.

free ebook

Du hast die Möglichkeit, es jetzt hier herunterzuladen!

Effect implementieren

Der Effect für das Laden von Flügen findet sich in der generierten 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 als Service fungiert, kann er andere Services per Dependency Injection erhalten. Auf diesem Weg erhält er unseren FlightService sowie ein Actions-Objekt. Letzteres ist ein Observable, das sämtliche Aktionen empfängt, die an den Store gesendet werden. Aus diesem Observable gilt es nun weitere Observables abzuleiten, die für die Ausführung der Effects zuständig sind.

Da hier lediglich loadFlights relevant ist, filtert der Effect mithilfe von ofType die empfangenen Actions. Der Operator switchMap delegiert dann an den FlightService. Der Einsatz von switchMap ist notwendig, weil find ein neues Observable zurückgibt, auf das gewechselt werden muss. Dieses Observable transportiert ein Flight-Array, das map in eine flightsLoaded-Action verpackt. Diese Action sendet @ngrx/effects an den Store, wo sie vom Reducer weiterverarbeitet wird.

Im Fehlerfall bildet catchError den aufgetretenen Fehler auf eine loadFlightsError-Action ab. Auch sie wird vom Reducer im Store verarbeitet.

Zugriff auf den Store aus einem Effect

Es gibt Situationen, in denen ein Effect auf den Store zugreifen muss. Angenommen, wir möchten alle Flüge aus dem Store speichern. Dafür sind folgende Actions vorgesehen:

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

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

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

Der zugehörige Effect könnte folgendermaßen 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>
    ) {}

}

Bemerkenswert ist hier der Operator concatLatestFrom, der von @ngrx/effects bereitgestellt wird. Er kombiniert die aktuelle Action mit zusätzlich abgefragten Informationen – in diesem Fall den Flügen aus dem Store, den sich der Effect über den Konstruktor injizieren lässt. Das von switchMap entgegengenommene Ergebnis ist ein Tupel, bestehend aus der Action und dem Flug-Array.

Hinweis: Es ist ebenfalls zulässig, zu speichernde Objekte über die Action an den Effect (und den Reducer) zu übergeben. Das folgende Beispiel zeigt eine Action zum Speichern eines einzelnen Fluges:

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

Den Store konsumieren

Die gute Nachricht zuerst: Der anspruchsvolle Teil liegt hinter uns. Nun gilt es nur noch, den Store aus den einzelnen Komponenten zu nutzen. Zur Veranschaulichung 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 wird mit unserer Sicht auf die Wurzel des Zustandsbaums typisiert. Die FlightSearchComponent bezieht nun das Observable flights$ mit den geladenen Flügen über die Methode select aus dem Store.

Um das Laden zu initiieren, versendet die Methode search die Action loadFlights. Dazu kommt die Methode dispatch des Stores zum Einsatz. Diese beiden Methoden, select und dispatch, sind tatsächlich die einzigen, die wir vom Store in Anspruch nehmen müssen.

Würde man die gesamte Komponente auf den Store umstellen, könnte auch die Abhängigkeit auf den FlightService entfallen. Dieser Ansatz reduziert die Komplexität der Komponenten, da sie lediglich mit dem Store interagieren müssen.

Debuggen mit dem Store

Die Chrome-Erweiterung Redux DevTools ist beim Debuggen von NGRX-basierten Lösungen äußerst hilfreich. Sie zeigt unter anderem den aktuellen Zustand des Stores sowie alle verwendeten Actions. Um unsere Lösung anzubinden, ist das Paket @ngrx/store-devtools erforderlich

npm install @ngrx/store-devtools

Danach gilt es, die IDE – wie Visual Studio Code – gegebenenfalls neu zu starten. Für die Nutzung des Pakets wird das StoreDevtoolsModule in das AppModule importiert:

// 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 { }

In der Regel kommt das StoreDevtoolsModule ausschließlich im Debug-Modus zum Einsatz. Im Produktionsbetrieb wäre der dadurch verursachte Overhead zu hoch.

Wichtig

Nach der Installation der Redux DevTools ist der Browser neu zu starten.

Wenn Sie nun Ihre Anwendung starten, finden Sie in den Chrome DevTools (F12) einen Reiter mit dem Namen Redux:

Redux DevTools im Browser

Dort lassen sich die einzelnen Actions, die dadurch ausgelösten Veränderungen am Zustandsbaum und der momentane Zustand der Anwendung einsehen. Darüber hinaus können Sie mithilfe des Schiebereglers am unteren Rand in der Zeit zurückgehen und so einen früheren Zustand wiederherstellen. Auch ist es möglich, einzelne Zustandsübergänge erneut abzuspielen, um sie im Detail zu analysieren.

Selektoren einsetzen

Bei der zuvor beschriebenen Vorgehensweise greift die FlightSearchComponent direkt auf den Store zu:

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

Das ist zwar einfach, bringt jedoch den Nachteil mit sich, dass die Komponente an die interne Struktur des Stores gekoppelt wird. Dies erschwert spätere Refactoring-Maßnahmen. Selektoren umgehen dieses Problem, indem sie die Zugriffspfade an zentrale Stellen verlagern. Zusätzlich können Selektoren eventuell durchgeführte Transformationen der abgerufenen Daten cachen. Das hat einen positiven Einfluss auf die Performance.

Ein erster Selektor

Um den Nutzen von Selektoren zu veranschaulichen, erweitern wir zunächst den FlightBookingState um die 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 der Flüge aufnehmen, die von der Anwendung nicht angezeigt werden sollen. Damit die Komponenten weder diese Logik noch den Aufbau des Zustandsbaums kennen müssen, definieren wir dafür einen Selektor. Die CLI hat hierfür bereits die Datei flight-booking.selectors.ts angelegt, die wir entsprechend erweitern können:

// 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 Lambda-Ausdrücke, die an createSelector übergeben werden, rufen die Eigenschaften flights und hide aus dem Zustandsbaum ab. Diese Werte reicht createSelector an den letzten Lambda-Ausdruck weiter. Dabei handelt es sich um einen sogenannten Projector, der die abgerufenen Werte auf das gewünschte Ergebnis abbildet. Dieses Ergebnis wird vom Selektor solange zwischengespeichert, bis sich die zugrunde liegenden Werte tatsächlich ändern.

Hinweis: Für createSelector existieren übrigens mehrere Überladungen. Sie ermöglichen die Übergabe von bis zu acht Lambda-Ausdrücken, die Werte aus dem Zustandsbaum abfragen.

Dieser Selektor lässt sich nun in der FlightSearchComponent einsetzen:

// 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 schachteln

Zur Vermeidung von Redundanz können Selektoren auch Werte anderer Selektoren heranziehen:

// 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 findet außerdem ein sogenannter Feature-Selektor Verwendung. Darunter versteht man einen Selektor, der die Wurzel des Zustandsbaums auf eine ihrer Eigenschaften abbildet. Der Name der betreffenden Eigenschaft ist als String zu übergeben. Das gezeigte Beispiel nutzt etwa die Konstante fromFlightBooking.flightBookingFeatureKey mit dem Wert flightBooking. Der Feature-Selektor liefert damit den FlightBookingState zurück.

Selektoren mit Parametern

Werden Parameter an einen Selektor übergeben, so ist dieser in eine Funktion zu verpacken. Die Funktion nimmt die Parameter auf und gibt 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 erwartet ein Array vom Typ exclude, das die IDs der Flüge enthält. Der Selektor schließt die auf diese Weise referenzierten Flüge von der Ergebnismenge aus.

Um mit diesem Selektor Daten abzurufen, genügt ein Aufruf von selectFlightsWithParams. Der so erhaltene Selektor liefert gemeinsam mit dem Store die gewünschten Einträge:

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

Meta-Reducer

Logik, die in vielen oder allen Reducern ausgeführt werden soll, lässt sich in sogenannte Meta-Reducer auslagern. Dabei handelt es sich um Reducer, die die üblichen Reducer kapseln. In der Regel delegieren sie an die herkömmlichen Reducer, können jedoch davor und danach eine eigene Bearbeitung anstoßen.

Im nachfolgenden Beispiel werden mithilfe eines Meta-Reducers sämtliche Zustände und Aktionen protokolliert:

// 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 den Meta-Reducer ausführt, ist er 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 { }

Abschlussbetrachtung

Mit NGRX wird der gesamte Anwendungszustand an einer zentralen Stelle geführt, was Doppelungen und widersprüchliche Daten vermeidet. Während jede Komponente den Zustand über Observables lesen kann, erfolgt das Ändern ausschließlich über das Absetzen von Actions. Diese Actions werden von Reducern verarbeitet, die den Zustand nach festgelegten Regeln aktualisieren. Selektoren ermöglichen den gezielten Zugriff und die Umwandlung von Daten, während Effects für Nebenläufigkeiten wie die Anbindung an das Backend zuständig sind.

Weiterführende Überlegungen zur Architektur

NGRX bietet eine solide Basis für die Strukturierung umfangreicher Anwendungen. Dennoch bleiben zentrale architektonische Fragen offen:

  • Welche Kriterien helfen dabei, eine Anwendung in kleinere, überschaubare Module zu zerlegen?
  • Wie lässt sich eine übermäßige Verflechtung dieser Module untereinander vermeiden?
  • Welche etablierten Entwurfsmuster unterstützen diesen Prozess?
  • Sollte man eine monolithische, aber gut strukturierte Anwendung bevorzugen oder auf Micro Frontends setzen?
  • Welche Rolle spielt dabei das neue Module-Federation-Konzept?

Unser kostenloses eBook beantwortet diese und weitere Fragen im Detail:

free ebook

Hier direkt herunterladen!