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 [1].
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.
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?
Das dreitägige ReactJS Intensiv-Training zum Vorankommen. Du lernst von den Grundlagen bis Hooks, alles, um dein eigenes React-Projekt erfolgreich zu starten.
mit Hans-Christian Otto, Program Chair der React Days
Kurz gesagt kann grundsätzlich jede Eigenschaft einer Applikation ein Teil des States sein.
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
Das Projekt NgRx hilft uns bei der Herausforderung, State in einer Angular-Anwendung zu verwalten [2]. 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.
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 [3]. 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).
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).
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.
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 eine 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).
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 eine Payload, mit der 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 eine gleichnamige Property auf der Klasse (Listing 3).
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 Action-Klassen, exportiert (Listing 4).
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.
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).
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
}
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;
}
}
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). 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 die zu bearbeitende Property in einer neuen Zeile mit den Informationen, die an die Actions als Metadaten über die 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 – eines 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.
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.
Explore Vitest, the JavaScript testing framework built for Vite, with React, Vue, and Node.js support plus modern features like ECMAScript modules.
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).
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).
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).
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).
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)))
)
);
}
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 einer bestimmten Property, die wir jetzt schon als String definieren können: export const featureStateName = 'todoFeature';
Wir erstellen ein neues Objekt, auf dem wir den Reducer auf eine 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 der Property todo, die 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).
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 die Haupt-Property des States zu extrahieren, gibt es eine Funktion createFeatureSelector aus dem @ ngrx/store-Paket. Er selektiert die Property erster Ebene, die des kompletten Features (Listing 12).
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).
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). Mittlerweile sieht unser Store-Ordner wie in Abbildung 2 aus.
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
);
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([ ... ]).
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 {}
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). 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 eine neue Property auf dem Root State mit dem Namen des Features.
In unserem Fall exportieren wir sie aus der index.ts-Datei und es heißt export const featureStateName = 'todoFeature';. Anschließend können wir es gemäß Listing 16 registrieren.
Listing 16
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 StoreModule.forFeature(featureStateName, todoReducers) erweitern wir also den Root State um ein Property todoFeature. Schauen wir uns den State der kompletten Applikation nun einmal genauer an:
{
todoFeature: ...
}
Der Wert dieser Property ist in der Variable 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.
Listing 17
{
todoFeature: {
todo: {
items: Todo[];
selectedItem: Todo;
}
}
}
Der todoReducer war eine Funktion, die uns einen ReducerTodoState zurückgibt. In der Datei store/todo.reducer.ts ist er, wie man sehen kann, folgendermaßen definiert:
export interface ReducerTodoState {
items: Todo[];
selectedItem: Todo;
}
Den Wert der todo-Property auf dem State Object sieht man in Listing 17. Schauen wir uns nun den createFeatureSelector noch einmal an: genau dieses JavaScript Object verwenden wir als Grundlage für unsere Properties:
export const getTodoFeatureState = createFeatureSelector<TodoState>(
featureStateName
);
Dieser FeatureSelector nimmt nun den kompletten State (Listing 18) und selektiert die Property mit dem Namen featureStateName, in unserem Fall ist das todoFeature. 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 die Property todo.item etc. zu.
Listing 18
{
todoFeature: {
todo: {
items: Todo[];
selectedItem: Todo;
}
}
}
So wird der State aufgebaut, an ein Modul appliziert, und wir greifen via Selectors auf die Properties zu.
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:
this.items$ = this.store.pipe(select(getAllUndoneItems));
Eine komplette Component könnte also aussehen wie in Listing 19.
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 die entsprechende Payload mitgeben (Listing 20).
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).
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>
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.
Fabian Gosebrink ist Google Developer Expert für Angular und Webtechnologien, Microsoft MVP und Webentwickler im Bereich ASP.NET Core und Angular. Als Professional Software Engineer, Consultant und Trainer berät und unterstützt er Kunden bei der Umsetzung von Webapplikationen im Front- bzw. Backend bis hin zum mobilen Bereich.