Development

Stein auf Stein

State-Management mit Angular und NgRx
Keine Kommentare

Die Frontend-Plattform Angular bietet uns viele Möglichkeiten, gut strukturierte, wart- und testbare Architekturen zu entwickeln. Die vielen Bausteine einer Applikation machen das Trennen der Verantwortlichkeiten sehr einfach und nachvollziehbar. Daher ist Angular auch für große Businessapplikationen ein passender Kandidat.

Gerade in großen Businessapplikationen ist das Behandeln des Status (State) der Anwendung eine der größten Herausforderungen, der wir uns beim Schreiben der Applikation stellen können. Um den Überblick zu behalten, ist es wichtig, den aktuellen State der Applikation greifbar zu machen, abzubilden und Änderungen daran nachvollziehbar vorzunehmen.

Um diese komplexe Aufgabe zu meistern, haben sich mit der Entwicklung von Angular Projekte entwickelt, die sich dieses Problems annehmen und uns Entwicklern eine Möglichkeit geben, den State einer Applikation zu lesen, ihn zu manipulieren und abzubilden.
In diesem Artikel wollen wir zu Beginn den Begriff State erklären, einen Blick auf das Projekt NgRx werfen und erläutern, welches Problem NgRx lösen kann. Anschließend werden wir die technische Seite betrachten sowie die Bestandteile von NgRx in einer Angular-Applikation mit Hilfe von Actions, Reducern und Selectors erklären. Abschließend demonstrieren wir das Konsumieren eines Stores in einer Component.

Wir schauen uns eine simple To-do-Applikation an, die ihre Items mit Hilfe von NgRx aus einem Store holt, diesen mit dem Backend synchronisiert und an der Benutzeroberfläche anzeigt.

Der vollständige Code zum Beispiel in diesem Artikel steht Ihnen auf GitHub zur Verfügung.

Die Beispielapplikation

Die Applikation ist eine einfache To-do-Applikation, die To-do-Items lesen und schreiben kann. Sie kann Items als erledigt markieren und via Routing auf die Detailseite eines einzelnen Items verweisen. Sie ist eine einfach gehaltene Aufgabenverwaltungsapplikation mit einem Featuremodul todo, das die Container- und Presentational-Komponenten enthält. Die Presentational Components empfangen Daten und kümmern sich darum, wie Daten angezeigt werden; die Container-Components kommunizieren mit dem Backend über Services, die über das Core-Modul eingebunden werden.

Was ist State?

Der State einer Applikation kann sich vom kleinsten Detail bis hin zu einem großen, sichtbaren Status einer Applikation erstrecken. Es kann – je nach Anwendung – alles als State betrachtet und darin gespeichert werden. Ist das Menü auf- oder zugeklappt? Welches Theme ist angewählt? Wie viele Datensätze stelle ich gerade dar? Auf welcher Seite bin ich gerade? Bin ich eingeloggt? Kurz gesagt kann grundsätzlich jede Eigenschaft einer Applikation ein Teil des States sein.

Das Redux-Pattern

In Applikationen können sich die Eigenschaften des States ändern. Sprich: Jedes Mal, wenn der Benutzer mit meiner Applikation arbeitet, wenn sie neue Werte über ein WebSocket empfängt oder wenn aufgrund einer Reaktion auf ein Event Eigenschaften an meiner Applikation geändert werden, verändert sich auch der State der Applikation.

Das Redux-Pattern sieht vor, dass man jederzeit einen prognostizierbaren State erstellt, hält und abfragen kann. Hierbei sollte der State immer der einzige Punkt sein, auf den meine Applikation sich bezieht. Das bedeutet, der State ist die eine Single Source of Truth, also die einzige Quelle der Wahrheit. Es gibt genau einen State in der Applikation und er allein gilt als Basis und bestimmt, was die Anwendung darstellt und wie sie sich verhält.

Weiter sollte der State nur lesend verfügbar sein. Veränderungen direkt am State dürfen nicht möglich sein, sondern sollten immer per spezieller Aktion getriggert werden, die einen neuen aktualisierten State als Ergebnis liefert. Um einen neuen State zu erhalten, benötigt man also den alten State und die entsprechende Aktion (Action). Das Ergebnis ist ein neues State Object, das dann wieder entsprechendes Verhalten oder Aussehen der Applikation nach sich ziehen kann.
Änderungen am State finden also mit Actions statt. Grundsätzlich gilt: Alter State + Action = neuer State.

Hierbei werden Veränderungen am State mit Funktionen bearbeitet, die bei gleicher Eingabe immer dasselbe Ergebnis haben: Pure Functions. Diese Funktionen nennt man Reducer. Sie manipulieren nicht ein existierendes State Object, sondern geben immer ein neues State Object zurück inklusive der Änderungen, die man via Action forciert hat.

Ein Reducer ist eine Funktion, die den alten State und eine Action als Parameter nimmt und ein neues State Object zurückgibt. Der State ist somit immutable:

 
(currentState, action) => newState

Was ist NgRx?

Das Projekt NgRx hilft uns bei der Herausforderung, State in einer Angular-Anwendung zu verwalten. Es gibt uns die Möglichkeit, den Status abzufragen, ihn zu verändern und zu verwalten. NgRx ist ein Projekt, unter dem mehrere Bibliotheken verfügbar sind, die helfen, Status nach dem Redux-Pattern zu bearbeiten. NgRx abstrahiert für uns Entwickler Klassen wie Actions, Selector oder Store und hilft uns, das Redux-Pattern in einer Angular-Applikation abzubilden.

Der Store in NgRx bildet die Verknüpfung von Reducern, Actions und States. Er nimmt Actions entgegen und leitet sie an einen Reducer weiter; man kann sich auf ihm auch für Aktualisierungen an einem State registrieren.

Doch genug der Theorie, schauen wir uns das Ganze in Aktion an. Wir haben unsere Applikation in Featuremodule unterteilt. Im Folgenden schauen wir uns an, wie wir im Featuremodul Todo mit NgRx und einem Store arbeiten, den State manipulieren und Veränderungen im UI abbilden können.

Installieren der Paktete und Anlegen der Dateien und Ordner

Wir können in unserer Applikation mit npm install @ngrx/store @ngrx/effects die erforderlichen Abhängigkeiten installieren. Das Angular CLI hat auch einen Befehl zum Scaffolding von Dateien und Ordnern eingebaut: ng add @ngrx/store. Hierbei wird etwas Code schon von vornherein generiert. Falls der nicht passt, kann man ihn mit verschiedenen Parametern anpassen. In unserem Beispiel werden wir die Dateien und Ordner selbst anlegen.

Sind die Pakete installiert, können wir im Featureordner todo einen Folder store anlegen (Abb. 1).

Abb. 1: Dieser Folder verwaltet unseren kompletten Store und den Status des Featuremoduls „Todo“

Erstellen des States

Als Nächstes müssen wir den State des Featuremoduls erstellen. Der State ist nur ein JavaScript-Objekt, das alles speichern soll, was wir im State ablegen wollen. In dem Fall sind das nur unsere To-do-Items und – falls es dies gibt – das gerade ausgewählte To-do-Item. In der Datei todo.reducer.ts findet unser State seinen Platz:

export interface ReducerTodoState {
  items: Todo[];
  selectedItem: Todo;
}

Interfaces sind in TypeScript passend für Typsicherheit, somit können wir einen initialState erstellen, der uns das geschriebene Interface erfüllt und die Properties mit den Standardwerten initialisiert (Listing 1).

export interface ReducerTodoState {
  items: Todo[];
  selectedItem: Todo;
}

export const initialState: ReducerTodoState = {
  items: [],
  selectedItem: null,
};

Der State des Todo-Featuremoduls ist beliebig erweiterbar; alles, was das Todo-Modul angeht, findet hier seinen Platz.

Definieren der Actions

Wir wissen, dass wir den State nur mit Actions verändern können. Somit müssen wir im nächsten Schritt die Actions definieren, die wir an den Store applizieren wollen, um ein neues State-Objekt erhalten zu können. Hierbei sind Actions simple TypeScript-Klassen mit einem eindeutigen Identifier. Als Property können wir hier im Konstruktor einen payload angeben. Dabei handelt es sich um Metadaten, die wir der Action mit auf dem Weg geben können, damit sie den State passend verändern kann. Eine Action, die ein bestimmtes To-do-Item abrufen soll, bekommt beispielsweise die ID dieses Items mit, damit wir dies später aus der Action extrahieren und zur HTTP-Abfrage verwenden können.

In einer erstellten Datei todo/store/todo.actions.ts definieren wir eindeutige Identifier für alle kommenden Actions in einem enum (Listing 2).

export enum ActionTypes {
  LoadAllTodos = '[Todo] Load Todos',
  LoadAllTodosFinished = '[Todo] Load Todos Finished',

  LoadSingleTodo = '[Todo] Load Single Todo',
  LoadSingleTodoFinished = '[Todo] Load Single Todo Finished',

  AddTodo = '[Todo] Add Todo',
  AddTodoFinished = '[Todo] Add Todo Finished',

  SetAsDone = '[Todo] SetAsDone',
  SetAsDoneFinished = '[Todo] SetAsDone Finished',
}

Hierbei ist es sehr übersichtlich, wenn man das Feature in eckige Klammern vor den Identifier schreibt; auch für das Debuggen ist das von Vorteil.

Jetzt können wir Klassen erstellen, die als Identifier (type) den passenden enum-Wert erhalten. Falls Sie es benötigen, können Sie zudem einen Payload, mit dem wir die Action mit Daten versehen können, von der aus dem Paket kommenden @ngrx/store-Klasse Action ableiten.

Hierbei wird die Kurzschreibweise zum Erstellen von Properties in einer TypeScript-Klasse genutzt. Der Parameter im Konstruktor mit dem Accessor vor dem Parameter erstellt netterweise gleichzeitig ein gleichnamiges Property auf der Klasse (Listing 3).

import { Action } from '@ngrx/store';
import { Todo } from '../../models/todo';

export enum ActionTypes {}
// ...

export class LoadAllTodosAction implements Action {
  readonly type = ActionTypes.LoadAllTodos;
}

export class LoadAllTodosFinishedAction implements Action {
  readonly type = ActionTypes.LoadAllTodosFinished;
  constructor(public payload: Todo[]) {}

export class LoadSingleTodoAction implements Action {
  readonly type = ActionTypes.LoadSingleTodo;
  constructor(public payload: string) {}
}

export class LoadSingleTodoFinishedAction implements Action {
  readonly type = ActionTypes.LoadSingleTodoFinished;
  constructor(public payload: Todo) {}
}

export class AddTodoAction implements Action {
  readonly type = ActionTypes.AddTodo;
  constructor(public payload: string) {}
}

export class AddTodoFinishedAction implements Action {
  readonly type = ActionTypes.AddTodoFinished;
  constructor(public payload: Todo) {}
}

export class SetAsDoneAction implements Action {
  readonly type = ActionTypes.SetAsDone;
  constructor(public payload: Todo) {}
}

export class SetAsDoneFinishedAction implements Action {
  readonly type = ActionTypes.SetAsDoneFinished;
  constructor(public payload: Todo) {}
}

Als Letztes bilden wir einen Aggregatstyp, der alle Klassentypen vereint. Dieser wird, genauso wie die enums und die Actionklassen, exportiert (Listing 4).

import { Action } from '@ngrx/store';
import { Todo } from '../../models/todo';

export enum ActionTypes {}
// ...

export class LoadAllTodosAction implements Action {
    readonly type = ActionTypes.LoadAllTodos;
}
// Alle weiteren Action-Klassen

export type TodoActions =
  | AddTodoAction
  | SetAsDoneAction
  | AddTodoFinishedAction
  | SetAsDoneFinishedAction
  | LoadAllTodosAction
  | LoadAllTodosFinishedAction
  | LoadSingleTodoFinishedAction
  | LoadSingleTodoFinishedAction;

Der Typ TodoActions fasst also alle Klassen zusammen, die als Todoaction infrage kommen könnten.

Hinzufügen eines Reducers

Mit einem State und den passenden Actions können wir nun einen Reducer schreiben. Ein Reducer ist nichts anderes als eine Funktion, die einen State (ReducerTodoState) und eine Action (Typ TodoActions) als Parameter erhält und einen neuen State (ReducerTodoState) zurückgibt. Ein Reducer ist der einzige Punkt unserer Applikation, in dem ein neues State-Objekt erzeugt wird. Jede Änderung an einem State geht von einem Reducer aus.

In der Datei store/todo.reducer.ts definieren wir den Reducer als normale Funktion (Listing 5).

import { Todo } from '../../models/todo';
import { ActionTypes, TodoActions } from './todo.actions';

export interface ReducerTodoState {
  items: Todo[];
  selectedItem: Todo;
}

export const initialState: ReducerTodoState = {
  items: [],
  selectedItem: null,
};

export function todoReducer(
  state = initialState,
  action: TodoActions
): ReducerTodoState {
  // manipulate state
}

Jede Action, die später an den Store gegeben wird, kann nun hier behandelt werden. Wir können nun im Reducer mit Hilfe eines switch/case auf die Actions reagieren, die den State manipulieren sollen (Listing 6).

import { Todo } from '../../models/todo';
import { ActionTypes, TodoActions } from './todo.actions';

export interface ReducerTodoState {
  items: Todo[];
  selectedItem: Todo;
}

export const initialState: ReducerTodoState = {
  items: [],
  selectedItem: null,
};

export function todoReducer(
  state = initialState,
  action: TodoActions
): ReducerTodoState {
  switch (action.type) {
    case ActionTypes.AddTodoFinished: {
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    }

    case ActionTypes.LoadAllTodosFinished: {
      return {
        ...state,
        items: [...action.payload],
      };
    }

    case ActionTypes.LoadSingleTodoFinished: {
      return {
        ...state,
        selectedItem: action.payload,
      };
    }

    case ActionTypes.SetAsDoneFinished: {
      const index = state.items.findIndex(
        x => x.id === action.payload.id
      );

      state.items[index] = action.payload;

      return {
        ...state,
      };
    }

    default:
      return state;
  }
}

Wichtig ist hierbei, dass immer ein neues Objekt zurückgegeben wird:

case MyAction: {
  return {
    // new State Object
  };
}

Wir benutzen dabei den Spread-Operator von ES6 (`…`) um das alte State-Objekt in das neue Objekt zu mergen:

return {
  ...state
}

Dabei überschreiben wir das zu bearbeitende Property in einer neuen Zeile mit den Informationen, die an die Actions als Metadaten über das Payload Property mitgegeben werden:

return {
  // return a new object
  ...state, // merge the old state in it
  items: [...action.payload], // overwrite the property ‘items’ with a new array spreading the payload in it
};

Diese Funktion des Reducers liefert uns bei gleichem Input immer das gleiche Ergebnis – eins der wichtigsten Konzepte von Reducern.

Im Default-Case geben wir den State ohne Manipulation zurück:

default:
  return state;

Im Reducer reagieren wir auch nicht unbedingt auf alle Arten von Actions. Nur, falls wir direkt den State verändern wollen, reagieren wir auf die gewünschte Action. Die restlichen Actions triggern eventuell Seiteneffekte, sogenannte Effects. Die schauen wir uns jetzt an.

Asynchrones Arbeiten mit Effects

Vor dem Hintergrund, dass Reducer immer dasselbe Ergebnis bei denselben Parametern geben, haben wir das Problem der Seiteneffekte; angenommen, wir würden HTTP-Kommunikation in den Reducer setzen, wäre das Ergebnis nicht mehr vorhersagbar. Denn wie der Server reagiert (Ergebnis 404/500/200/…), wissen wir nicht. Somit wäre das Ergebnis nicht mehr absehbar und der Reducer würde eventuell auch bei demselben Input unterschiedliche Ergebnisse bringen. Weiter sind Reducer immer synchron, HTTP-Kommunikation ist allerdings asynchron.

Wir können somit asynchrone Operationen in von NgRx angebotene Effects auslagern. Sie bieten uns eine Stelle für HTTP-Kommunikation oder jede andere Art von asynchronem Verhalten.

Wir erstellen eine neue Datei todo.effects.ts und eine Klasse TodoEffects. Wir dekorieren diese Klasse mit dem Decorator @Injectable(), da die Klasse einen Actions-Typ injiziert bekommt und einen Service zur HTTP-Kommunikation benutzt (Listing 7).

@Injectable()
export class TodoEffects {
  constructor(private actions$: Actions, private todoService: TodoService) {
    // Effects
  }
}
```
// Ein Effect wird mit dem @Effect()-Decorator versehen und filtert aus den übergebenen actions$ den Typ heraus, auf den der spezielle Effekt reagieren will. Diesmal als Observable mittels des typeof()-Operators.

```typescript
 @Effect()
  addTodo$ = this.actions$.pipe(
    ofType(ActionTypes.AddTodo),
    // ...
  );

Der typeof()-Operator gibt uns die konkrete Action zurück, die wir weiter verarbeiten können. Mittels switchMap() lösen wir das erste Observable auf und geben über den TodoService ein neues zurück (Listing 8).

@Effect()
  addTodo$ = this.actions$.pipe(
    ofType(ActionTypes.AddTodo),
    switchMap((action: AddTodoAction) =>
      this.todoService
        .addItem(action.payload)
        // ...
    )
  );

Nachdem der HTTP Call getätigt wurde, mappen wir das Observable mit einem map()-Operator und geben eine neue Action zurück, die über den NgRx Store automatisch wieder an den Reducer gegeben und – da wir es ja entsprechend implementiert haben – dort auch behandelt wird (Listing 9).

@Effect()
  addTodo$ = this.actions$.pipe(
    ofType(ActionTypes.AddTodo),
    switchMap((action: AddTodoAction) =>
      this.todoService
        .addItem(action.payload)
        .pipe(
          map(
            (todo: Todo) => new AddTodoFinishedAction(todo)
          )
        )
    )
  );

Als Payload geben wir in diesem Fall das Ergebnis des HTTP Calls mit der Action zurück, in diesem Fall das hinzugefügte To-do-Item.

Beide Files – Reducer und Effects – hören auf alle Actions, die wir am Store applizieren. Wir behandeln in unserem Beispiel die initialen Actions wie AddTodo in den Effects; dort setzen wir den HTTP Call ab, geben eine AddTodoFinished-Action zurück, die den State wiederum verändert und im Reducer behandelt wird. Die anderen Effects implementieren wir ähnlich (Listing 10).

// imports

@Injectable()
export class TodoEffects {
  constructor(private actions$: Actions, private todoService: TodoService) {}

  @Effect()
  loadTodos$ = this.actions$.pipe(
    ofType(ActionTypes.LoadAllTodos),
    switchMap(() =>
      this.todoService
        .getItems()
        .pipe(
          map(
            (todos: Todo[]) => new LoadAllTodosFinishedAction(todos)
          )
        )
    )
  );

  @Effect()
  loadSingleTodos$ = this.actions$.pipe(
    ofType(ActionTypes.LoadSingleTodo),
    switchMap((action: LoadSingleTodoAction) =>
      this.todoService
        .getItem(action.payload)
        .pipe(
          map((todo: Todo) => new LoadSingleTodoFinishedAction(todo))
        )
    )
  );

  @Effect()
  addTodo$ = this.actions$.pipe(
    ofType(ActionTypes.AddTodo),
    switchMap((action: AddTodoAction) =>
      this.todoService
        .addItem(action.payload)
        .pipe(map((todo: Todo) => new AddTodoFinishedAction(todo)))
    )
  );

  @Effect()
  markAsDone$ = this.actions$.pipe(
    ofType(ActionTypes.SetAsDone),
    switchMap((action: SetAsDoneAction) =>
      this.todoService
        .updateItem(action.payload)
        .pipe(map((todo: Todo) => new SetAsDoneFinishedAction(todo)))
    )
  );
}

State Selector anbieten

Der vorletzte Schritt erfordert es, den State der Applikation mundgerecht anzubieten. Hierfür erstellen wir eine index.ts-Datei, damit wir später sauber aus dem Ordner ./store/ (äquivalent zu ./store/index.ts) importieren können. Diese Datei bietet uns die Möglichkeit, den State so anzubieten, dass Components möglichst wenig Mapping- oder Transformationsarbeit erledigen müssen.

Da wir uns in einem Featuremodul befinden, registrieren wir später den Feature-State unter einem bestimmten Property, das wir jetzt schon als String definieren können: export const featureStateName = ‚todoFeature‘;

Wir erstellen ein neues Objekt, auf dem wir den Reducer auf ein Property mappen. Falls wir im Todo-Feature mehrere Reducer haben sollten, können wir dieses Objekt erweitern. Um typsicher zu bleiben, definieren wir zusätzlich ein Interface für den TodoState mit dem Property todo, das den Typ des Reducers ReducerTodoState erhält. Das Objekt hat den Typ ActionReducerMap, der generisch ist. In diesem Fall geben wir das Interface TodoState als Typ. Somit haben wir ein Objekt für das Featuremodul, das mit zukünftigen Reducern erweiterbar ist (Listing 11).

import { ActionReducerMap } from '@ngrx/store';
import { ReducerTodoState, todoReducer } from './todo.reducer';

export const featureStateName = 'todoFeature';

export interface TodoState {
  todo: ReducerTodoState;
}

export const todoReducers: ActionReducerMap<TodoState> = {
  todo: todoReducer,
};

Wir registrieren später den TodoState unter dem featureStateName = ‚todoFeature‘ auf dem Modul. Um unseren Components nach außen nun saubere Teile des States anbieten zu können, und damit diese nicht groß mappen und sich über Properties hangeln müssen, können wir mit Selectors konkrete Teile des States anbieten. Da wir als Grundlage immer das komplette State Object nehmen, müssen wir erstmal den Teil des States extrahieren, den wir behandeln wollen, und dann spezifische Selectors auf Properties von diesem State erstellen. Um das Haupt-Property des States zu extrahieren, gibt es eine Funktion createFeatureSelector aus dem @ngrx/store-Paket. Er selektiert das Property erster Ebene, das des kompletten Features (Listing 12).

import { ActionReducerMap, createFeatureSelector } from '@ngrx/store';
import { ReducerTodoState, todoReducer } from './todo.reducer';

export const featureStateName = 'todoFeature';

export interface TodoState {
  todo: ReducerTodoState;
}

export const todoReducers: ActionReducerMap<TodoState> = {
  todo: todoReducer,
};

// extract the main property 'todoFeature' from the state object
export const getTodoFeatureState = createFeatureSelector<TodoState>(
  featureStateName
);

Diesen Feature-Selector können wir nun als Argument in eine weitere Funktion geben, die createSelector heißt und ebenfalls aus dem @ngrx/store-Paket kommt. Mit diesen Selectors können wir konkrete kleine Teile des States anbieten oder auch filtern oder zusammenfassen. Diese Selectors werden später von den Components verwendet.

Beispielsweise können wir vom State alle nicht erledigten Items herausfiltern (Listing 13).

export const getTodoFeatureState = createFeatureSelector<TodoState>(
  featureStateName
);

export const getAllUndoneItems = createSelector(
  getTodoFeatureState, // select featurestate from main state first
  (state: TodoState) => state.todo.items.filter(x => !x.done) // then return all undone items
);

Wir benutzen die Funktion createSelector, geben den Feature-Selector hinein, bekommen den TodoState als Ergebnis und filtern daraufhin die unerledigten Items heraus.

Das Gleiche können wir mit den erledigten Items und dem aktuell ausgewählten Item machen, das auch Teil des States ist (Listing 14).

import {
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
} from '@ngrx/store';
import { ReducerTodoState, todoReducer } from './todo.reducer';

export const featureStateName = 'todoFeature';

export interface TodoState {
  todo: ReducerTodoState;
}

export const todoReducers: ActionReducerMap<TodoState> = {
  todo: todoReducer,
};

export const getTodoFeatureState = createFeatureSelector<TodoState>(
  featureStateName
);

export const getAllUndoneItems = createSelector(
  getTodoFeatureState,
  (state: TodoState) => state.todo.items.filter(x => !x.done)
);

export const getAllDoneItems = createSelector(
  getTodoFeatureState,
  (state: TodoState) => state.todo.items.filter(x => x.done)
);

export const getSelectedItem = createSelector(
  getTodoFeatureState,
  (state: TodoState) => state.todo.selectedItem
);

Mittlerweile sieht unser Store-Ordner wie in Abbildung 2 aus.

Abb. 2: Aktueller „Store“-Ordner

Abb. 2: Aktueller „Store“-Ordner

State mit dem Modul verbinden und Form des State Objects

Unser Store ist nun fertig. Wir müssen diesen nur noch auf unserem Modul registrieren. Wir können Stores auf dem Root-Modul (AppModule) mit Storemodule.forRoot({ … }) und auf Featuremodulen mit StoreModule.forFeature(’nameOfFeature‘, { … }) registrieren. Die Effekte müssen wir ebenfalls registrieren. Hierfür gibt es die Methoden EffectsModule.forRoot([ … ]) oder EffectsModule.forFeature([ … ]).

Da wir in diesem Fall in einem Featuremodul arbeiten und der State über die komplette Applikation nur ein JavaScript-Objekt ist, können wir genau dieses JavaScript-Objekt auf dem Appmodul registrieren. Die Effekte auf root-level sind in diesem Beispiel ebenfalls nicht genutzt, also stellen wir ein leeres Array zur Verfügung (Listing 15).

import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';

@NgModule({
  declarations: [...],
  imports: [
    StoreModule.forRoot({}),
    EffectsModule.forRoot([]),
  ],
  bootstrap: [...],
})
export class AppModule {}

Zu diesem Zeitpunkt ist der State der Applikation, der on the fly von NgRx generiert wird, der folgende:

{
  // empty object...
}

Auf das Featuremodul TodoModule registrieren wir den State und die Effects mit forFeature(…). StoreModule.forFeature(’nameOfFeature‘, { … }) registriert nun ein neues Property auf dem Root State mit dem Namen des Features. In unserem Fall exportieren wir es aus der index.ts-Datei und es heißt export const featureStateName = ‚todoFeature‘;. Anschließend können wir es gemäß Listing 16 registrieren.

import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { featureStateName, todoReducers } from './store';
import { TodoEffects } from './store/todo.effects';

@NgModule({
  imports: [
    //...
    StoreModule.forFeature(featureStateName, todoReducers),
    EffectsModule.forFeature([TodoEffects]),
  ],
  declarations: [
    // ...
  ],
})
export class TodoModule {}

Mit der Zeile StoreModule.forFeature(featureStateName, todoReducers) erweitern wir also den Root State um ein Property todoFeature.

Schauen wir uns den State der kompletten Applikation nun mal genauer an:

 
{
  todoFeature: ...
}

Der Wert dieses Properties ist in der Variablen todoReducers deklariert, die wir ebenfalls in der Registrierung angeben. Sie beschreibt jedoch ebenfalls nur ein Objekt, das wir in der store/index.ts schon programmiert haben:

export const todoReducers: ActionReducerMap<TodoState> = {
  todo: todoReducer,
};

Somit ist das State Object, das NgRx generiert, nun das folgende:

{
  todoFeature: {
    todo: //
  }
}

Schauen wir uns den Wert von todo im State Object genauer an, stellen wir fest, dass er auf den todoReducer verweist. Der todoReducer war eine Funktion, die uns einen ReducerTodoState zurückgibt.

In der Datei store/todo.reducer.ts ist er definiert mit:

export interface ReducerTodoState {
  items: Todo[];
  selectedItem: Todo;
}

Den Wert unseres todo-Properties auf dem State Object ist Listing 17 zu entnehmen.

{
  todoFeature: {
    todo: {
      items: Todo[];
      selectedItem: Todo;
    }
  }
}

Wenn wir nun den createFeatureSelector nochmal anschauen, können wir sehen, dass wir genau dieses JavaScript Object als Grundlage für unsere Properties verwenden:

export const getTodoFeatureState = createFeatureSelector<TodoState>(
  featureStateName
);

Dieser FeatureSelector nimmt den kompletten State (Listing 18) und selektiert das Property mit dem Namen featureStateName, in unserem Fall todoFeature.

{
  todoFeature: {
    todo: {
      items: Todo[];
      selectedItem: Todo;
    }
  }
}

Das Ergebnis dieses FeatureSelectors ist das folgende:

todo: {
  items: Todo[];
  selectedItem: Todo;
}

Wenn wir nun mit createSelector eine Abfrage darauf starten, geben wir das bereits selektierte todoFeature vom Typ TodoState in die zweite Abfrage hinein:

export const getAllUndoneItems = createSelector(
  getTodoFeatureState, // extract the 'todoFeature' --> todo: { items: [], selectedItem: ... }
  (state: TodoState) => state.todo.items.filter(x => !x.done)
);

In der zweiten Zeile dieses Selectors (state: TodoState) => state.todo.items.filter(x => !x.done); nehmen wir das Ergebnis des ersten Selectors, dem vorigen FeatureSelector, entgegen, taufen das Ergebnis state und greifen auf das Property todo.item etc. zu.

So wird der State aufgebaut, an ein Modul appliziert, und wir greifen via Selectors auf die Properties zu.

State in einer Component konsumieren

Da wir nun den State und die Selectors demystifiziert haben, können wir den Store in unseren Components injecten und ihn verwenden.

Wollen wir nun den Store dazu bringen, alle To-do-Items abzufragen – dazu hören wir in den Effects auf eine GetAllTodos Action – können wir sie mit der Funktion dispatch(…) an den Store applizieren:

this.store.dispatch(new LoadAllTodosAction());

Über die Selectors können wir auf unseren Store zugreifen und aus ihm die Informationen abfragen, die uns in der Component interessieren. Hierbei bekommen wir ein Observable zurück. Wir registrieren uns also auf alle Änderungen an diesem State Object und bekommen es in eine Variable gepusht:

Eine komplette Component könnte also aussehen wie in Listing 19.

import { getAllDoneItems, getAllUndoneItems, TodoState } from '@app/todo/store';
import { LoadAllTodosAction } from '@app/todo/store/todo.actions';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';

@Component(...)
export class ContentComponent implements OnInit {

  items$: Observable<Todo[]>;
  doneItems$: Observable<Todo[]>;

  constructor(private store: Store<TodoState>) {}

  ngOnInit() {
    this.items$ = this.store.pipe(select(getAllUndoneItems));
    this.doneItems$ = this.store.pipe(select(getAllDoneItems));

    this.store.dispatch(new LoadAllTodosAction());
  }
}

Falls wir in dieser Component das Event fangen, das auslöst, dass ein neues Item hinzugefügt werden soll, können wir die AddTodoAction() an den Store applizieren und den entsprechenden Payload mitgeben (Listing 20).

import { getAllDoneItems, getAllUndoneItems, TodoState } from '@app/todo/store';
import { LoadAllTodosAction, AddTodoAction } from '@app/todo/store/todo.actions';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';

@Component(...)
export class ContentComponent implements OnInit {

  items$: Observable<Todo[]>;
  doneItems$: Observable<Todo[]>;

  constructor(private store: Store<TodoState>) {}

  ngOnInit() {
    // ...
  }

  addTodo(item: string) {
    this.store.dispatch(new AddTodoAction(item));
  }
}

Im Template können wir die Daten via Data Binding an die Presentational Component übergeben und uns auf die Events der Components registrieren (Listing 21).

<div class="starter-template">
  <h1>Todo</h1>
  <p>
    <app-todo-form (todoAdded)="addTodo($event)"></app-todo-form>
  </p>
  <p>
    <app-todo-list
      [items]="items$ | async"
      [doneItems]="doneItems$ | async"
    ></app-todo-list>
  </p>
</div>

Fazit

Im Verlauf dieses Artikels haben wir gesehen, wie man NgRx benutzen kann, um To-do-Items im UI anzuzeigen und sie an ein Backend zu schicken. Wir haben alle inkludierten Dateien und Ordner angeschaut und versucht, Selectors und den State zu entmystifizieren.

Dabei ist jedoch auch deutlich geworden, dass eine Menge an Code geschrieben werden muss, um das nötige Level an Abstrahierung zu erreichen: Für jede Aktion müssen neue Actions erstellt werden; auch können neue Reducer, Selectors etc. wichtig werden oder müssen angepasst werden. Das kann, wenn man es konsequent macht, sehr viel Code für ein kleines Feature bedeuten. Die Lernkurve ist recht steil, sodass gerade für Anfänger dieses Thema mehr Verwirrung als Klarheit stiften kann.

Mit NgRx können wir jedoch auch sehr elegant die große Hürde des State-Managements überwinden, die uns in der Frontend-Entwicklung immer wieder über den Weg läuft. Es wird leicht nachvollziehbar, was wann und wo in der Applikation passiert, das Debugging wird verbessert und Bugs werden schneller gefunden. Der Store ermöglicht einfaches Testing und die Nachvollziehbarkeit des Verhaltens der Applikation wird verbessert – und das vereinfacht die Applikation letztendlich wieder enorm. Der Code innerhalb einer Component für das Anfragen von Daten oder Triggern von Aktionen ist lediglich ein Einzeiler und unglaublich übersichtlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -