Redux

Ein andere Ansatz für das Halten & Verwalten von Daten ist ein zentrales State Management mit Redux. Redux hilft uns Dateninkonsistenz innerhalb unsere Anwendung zu vermeiden und Zustandsänderungen transparent sowie nachvollziehbar zu machen.

Der Zustand der Anwendung wird in einem nicht veränderbaren Store gespeichert. Durch Actions können Zustandsänderungen beauftragt werden. Der Reducer verarbeitet die Actions und errechnet aus dem alten Zustand sowie die Daten aus der Action den neuen Zustand, der im Store gespeichert wird. Reducer dürfen jedoch keine anderen Abhängigkeiten haben oder asynchrone Operationen ausführen.

Zu Interagieren mit externen Schnittstellen – wie dem Laden von externe Daten eignen sich deshalb Reducer nicht. Effects hören auf die Actions wie ein Reducer – verändern jedoch nicht den Zustand. Wir können einen Effect nutzen, um die Daten zuladen. Im Erfolgsfall lösen wir einen weitere Action (Load_Success) mit den geladen Daten aus. Ein Reducer schreibt die Daten anschließend in den Store.

Soviel zu den Grundlagen: Schauen wir uns den Programmcode an!

Vorbereitungen

Für den Einsatz von Redux in Angular gibt es verschiedene Frameworks:

In dieses Blogpost werden wir NgRx verwenden.

Für die Installation werden die folgenden Pakete zu deinem Angular-Projekt hinzufügt:
npm install --save @ngrx/store @ngrx/effects

Ziel der Anwendung: Wir wollen eine Liste an Blogpost von einer Schnittstelle laden. Dafür erstellen wir eine Entität-Klasse, die unsere Daten für einen Blogpost halten wird. Es ist die gleiche Entität-Klasse wie wir schon im Teil Externe Daten durch HTTP Client laden verwendet haben.

export class PostEntity {
    userId: number;
    id: number;
    title: string;
    body: string;

    static fromJson(obj: object): PostEntity {
        const newEntity = new PostEntity();

        if (obj.hasOwnProperty('userId')) {
            newEntity.userId = Number(obj['userId']);
        }

        if (obj.hasOwnProperty('id')) {
            newEntity.id = Number(obj['id']);
        }

        if (obj.hasOwnProperty('title')) {
            newEntity.title = obj['title'];
        }

        if (obj.hasOwnProperty('body')) {
            newEntity.body = obj['body'];
        }

        return newEntity;
    }
}

Zustand, Actions und Reducer

Definieren wir erstmal, was wir in unserem Zustand speichern wollen. Der Zustand ist nicht global über alle Objekte, die wir jemals verwenden wollen, sondern wir können für jede Teildomäne separat definieren. Wir wollen neben der Liste der Blogpost list: PostEntity[] auch kennen, ob die Liste schon geladen wurde (initLoad). Initial neben wir an, dass die Liste leer ist und noch geladen werden muss.

import {PostEntity} from './post.entity';

export class PostState {
    initLoad = false;
    list: PostEntity[] = [];
}

Zum Laden der Daten definieren wir uns drei Actions:

  • Load: Bitte Daten laden
  • LoadSuccess: Laden war erfolgreich und die Daten sind bereit für den Store
  • LoadFail: Laden war fehlerhaft
import {Action} from '@ngrx/store';
import {PostEntity} from './post.entity';

export enum PostActionTypes {
    Load = '[Post] Load',
    LoadSuccess = '[Post] Load Success',
    LoadFail = '[Post] Load Fail',
}

export class PostLoadAction implements Action {
    readonly type = PostActionTypes.Load;
}

export class PostLoadSuccessAction implements Action {
    readonly type = PostActionTypes.LoadSuccess;

    constructor(public posts: PostEntity[]) {

    }
}

export class PostLoadFailAction implements Action {
    readonly type = PostActionTypes.LoadFail;
}

Der Reducer berechnet aus der Action und dem alten Zustand, den neuen Zustand des Stores.

  • Bei Load und LoadFail wird der Zustand nicht geändert.
  • Bei LoadFail wird eine Fehlermeldung über die Konsole ausgegeben – ohne den Zustand zu ändern.
    Zur Vereinfachung haben wir an dieser Stelle auf eine Fehlermeldung an den Benutzer verzichtet.
  • Bei LoadSuccess werden die geladen Daten in die Liste gespeichert
    und die Markierung initLoad = true gesetzt.

Wichtig ist dabei die JavaScript Syntax const newState = {...state};: Wir ändern den Zustand nie direkt, sondern arbeiten auf deiner Kopie. Die vorgestellte Schreibweise {...state} – auch Object Spread Operator genannt – ist eine Kurzform von Object.assign({}, state).

export const initialState: PostState = new PostState();

export function postReducer(state = initialState, action: Action): PostState {
    switch (action.type) {

        case PostActionTypes.LoadSuccess: {
            const newState = {...state};
            newState.list = (action as PostLoadSuccessAction).posts;
            newState.initLoad = true;
            return newState;
        }
        case PostActionTypes.LoadFail:
        {
            console.error({error: action});
            return state;
        }

        default:
            return state;
    }
}

Jetzt bringen wir alles zusammen: Das AppModule benötigt noch einen Import für StoreModule.forRoot({post: postReducer}). Die Konfiguration würde funktionieren – nur würden nie die Daten geladen werden. Deshalb schauen wir uns im nächsten Abschnitt den Effect an. Im AppModule haben wir den notwendigen Import EffectsModule.forRoot([ PostLoadEffect]) hinzugefügt.

@NgModule({
    declarations: [
        AppComponent,
        CreateComponent,
        ListComponent
    ],
    imports: [
        BrowserModule,
        HttpClientModule,
        FormsModule,
        StoreModule.forRoot({post: postReducer}),
        EffectsModule.forRoot([
            PostLoadEffect,
        ])
    ],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule {
}

Effect: Daten vom Backend laden

Der Effect lädt die Daten von einer API. Schauen wir uns die Details an: Auf alle Actions Benachrichtigungen gehört.
Da uns jedoch nur ein bestimmter Aktionstyp interessiert, filtern wir die Action Benachrichtigungen mit ofType(PostActionTypes.Load). Nun betrachten wir im folgenden Programmcode nur die Actions PostActionTypes.Load, die die Backenddaten laden soll.

Mit this.http.get('https://jsonplaceholder.typicode.com/posts') holen wir uns die gewünschten Daten von der Schnittstelle. Wir transformieren im ersten Schritt in die gewünschte PostEntity Klasse. Im zweiten Schritt bauen wir uns eine PostLoadSuccessAction mit den gewünschten Daten. Im Fehlerfall möchten wir die PostLoadFailAction ausführen, dass wir mit catchError(() => of(new PostLoadFailAction())) definieren. Mit flatMap bzw. mergeMap führen wir den zweiten asynchronen Event Strom von this.http.get(...) zusammen (siehe auch http://reactivex.io/documentation/operators/flatmap.html).

@Injectable()
export class PostLoadEffect {

    // Hört auf die Aktion 'LOAD'
    @Effect()
    load$: Observable = this.action$.pipe(
        ofType(PostActionTypes.Load),
        mergeMap(action =>
            this.http.get('https://jsonplaceholder.typicode.com/posts').pipe(
                map((list: object[]) => list.map((e: object) => PostEntity.fromJson(e))),
                map((list: PostEntity[]) => new PostLoadSuccessAction(list)),
                catchError(() => of(new PostLoadFailAction()))
            )
        )
    );

    constructor(private http: HttpClient, private action$: Actions) {

    }
}

Jetzt haben wir fast alles: Redux ist aufgesetzt. Jetzt fehlen noch zwei Punkte: Erstens es muss die Action Load ausgelöst werden und zweitens wir müssen noch die Daten aus dem Store auslesen.

Load Action auslösen und Ergebnisse darstellen

Die Load-Action lösen wir in unserem Beispiel beim Erstellen der Komponente (ngOnInit) aus. Den Store können wir mithilfe von store.pipe(select('post')); auslesen. Die Referenz private store: Store<{ post: PostState }> bekommen wir mithilfe der Dependency Injektion von Angular.

@Component({
    selector: 'app-list',
    templateUrl: './list.component.html',
    styleUrls: ['./list.component.css']
})
export class ListComponent implements OnInit {
    postState$: Observable;

    constructor(private store: Store<{ post: PostState }>) {
        this.postState$ = store.pipe(select('post'));
    }

    ngOnInit() {
        this.store.dispatch(new PostLoadAction());
    }

    onDelete(post: PostEntity) {
        this.store.dispatch(new PostDeleteAction(post));
    }

}

Zum Darstellen des Observables postState$ nutzen wir die async-Pipe.

Das Codebeispiel geht noch weiter und zeigt, wie Datensätze hinzugefügt sowie gelöscht werden können. Den Programmcode findest du unter https://github.com/dornsebastian/angular-ngrx.

Passend zu diesen Blogbeitrag gibt es ein Podcast unter https://happy-angular.de/asynchrone-daten-laden-mit-redux-folge-23.

Im nächsten Teil schauen wir uns an, wie wir Dateien hochladen und downloaden können.