Skip to content

Instantly share code, notes, and snippets.

@zoitsa
Created February 25, 2019 21:36
Show Gist options
  • Save zoitsa/57c24d0e69b08fba4b52eeaa845ce2b4 to your computer and use it in GitHub Desktop.
Save zoitsa/57c24d0e69b08fba4b52eeaa845ce2b4 to your computer and use it in GitHub Desktop.

And ngRx was Good.

Intro

ngRx is the prescription for data in Angular.

We use ngRx as our front-end framework data management library of choice.

Table of Contents

  1. Full ngrx feature with API call
  2. Making chained API Calls using @ngrx/Effects

Full ngrx feature with API call

Description

A full ngrx feature uses Actions, Effects, and Reducers to update the application state. In this example, we'll also incorporate an ApiService to make an API call and use ngrx effects to handle Action side effects. Follow along with a standard Login example.

  1. Create actions src/app/actions/user.actions.ts
import { Action } from '@ngrx/store';
import { User } from '../../models/user';

export enum UserActionTypes {
  // 1. Actions syntax is consistent for API calls (NAME, NAME_COMPLETE, NAME_ERROR)
  // 2. The name is used for the initial dispatch, the complete / error are used to
  //    update the state with the response payload, which can be used to display on the view.
  LOGIN = '[User] LOGIN',
  LOGIN_COMPLETE = '[User] LOGIN_COMPLETE',
  LOGIN_ERROR = '[User] LOGIN_ERROR',
}

// 3. Be aware of whether a payload is necessary for the action and the appropriate type.
// In this example we are using User for "Login" and "LoginComplete" but any for "LoginError"

export class Login implements Action {
  readonly type = UserActionTypes.LOGIN;

  constructor(public payload: User) {}
}

export class LoginComplete implements Action {
  readonly type = UserActionTypes.LOGIN_COMPLETE;

  constructor(public payload: User) {}
}

export class LoginError implements Action {
  readonly type = UserActionTypes.LOGIN_ERROR;

  constructor(public payload: any) {}
}

export type UserActions
= Login
| LoginComplete
| LoginError;
  1. Dispatch your action in a smart component. In this case we'll dispatch when the user fills out the login form and clicks the button. src/app/auth/containers/login-page
onLogin(form) {
    this.store.dispatch(new Login(form));
  }
  1. Set up function in ApiService src/app/services/api.service.ts
  // 1. Separating our API call function
  // 2. Function name is specific to the type of request (POST, Login)
  // 3. Parameter is passed in with the correct Type

  postUserLogin(user: User) {
    return this.http.post(`${this.apiURL}/auth/login`, user)
      .pipe(
        tap((res: User) => {
          this.appAuth = `Bearer ${res.token}`;
          localStorage.setItem('usertoken', res.token);
        })
      );
  }
  1. Effects Create a User Effects file with a function to handle the Api call and response src/app/effects/user.effects.ts
  // The API service is used to handle the side effects of the actions.
  // We use rxjs methods to connect the NAME action to the specific ApiService function
  // and to dispatch the NAME_COMPLETE or NAME_ERROR to send to our reducer to update the state.

  @Effect()
  postLogin$: Observable<Action> = this.actions$.pipe(
    ofType<Login>(UserActionTypes.LOGIN),
    switchMap((action) => {
      return this.api.postUserLogin(action.payload)
        .pipe(
          mergeMap((user: User) => [
            new LoginComplete(user),
            new GetComplete(user.locations)
          ],
          ),
          catchError(errorHandler(LoginError))
        );
    }),
  );
  1. Reducers - Create User Reducers to update state

src/app/reducers/user.reducer.ts

export function reducer(state = initialState, action: UserActions | LocationActions): State {
  switch (action.type) {

  // On LOGIN - User state is the same as initial,
  // Set additional key values like isLoading: true
    case UserActionTypes.LOGIN:
      return {
        ...state, // login has initial User state
        hasError: false,
        errorMessage: null,
        isLoading: true
    };

    // On LOGIN_COMPLETE - User state is updated to the returned payload
    // Set additional key values like isLoading: false
    case UserActionTypes.LOGIN_COMPLETE:
      return {
        ...state,
        loggedIn: true,
        user: action.payload, // update User state with action payload
        isLoading: false,
        isAdmin: action.payload.isAdmin,
    };

    // On LOGIN_ERROR - User state is the same as initial
    // Set additional key values like errorMessage to the returned payload from server message, etc.
    case UserActionTypes.LOGIN_ERROR:
      return {
        ...state, //if error, User remains initial state
        errorMessage: action.payload, // update errorMessage with action payload
        hasError: true,
        isLoading: false,
      };
    default:
      return state;
  }
}

// Create slices of state to export for selector functions
export const getLoggedIn = (state: State) => state.loggedIn;
export const selectUser = (state: State) => state.user;
export const errorMessage = (state: State) => state.errorMessage;
  1. Selectors Create selectors in store src/app/reducers/index.ts to access slices of state. Here are some example selectors for User login:
export const selectUserState = (state: State) => state.user;

export const selectCurrentUser = createSelector(
  selectUserState,
  fromUser.selectUser
);

export const getLoggedIn = createSelector(
  selectUserState,
  fromUser.getLoggedIn
);

export const userErrorMessage = createSelector(
  selectUserState,
  fromUser.errorMessage
);

export const userHasError = createSelector(
  selectUserState,
  fromUser.hasError
);

export const userIsLoading = createSelector(
  selectUserState,
  fromUser.isLoading
);
  1. User selectors in Smart components src/app/dashboard/containers/home-page
export class HomePageComponent implements OnInit {
  user$: Observable<any>;

  constructor(
    private store: Store<fromRoot.State>,
    private actions$: CMSActions
  ) {
    this.user$ = store.select(fromRoot.selectCurrentUser);  // assign an observable to a selector from the Store
  }

Making chained API Calls using @ngrx/Effects

Purpose

This recipe is useful for cooking up chained API calls as a result of a single action.

Description

In the below example, a single action called POST_REPO is dispatched and it's intention is to create a new repostiory on GitHub then update the README with new data after it is created.
For this to happen there are 4 API calls necessary to the GitHub API:

  1. POST a new repostiry
  2. GET the master branch of the new repository
  3. GET the files on the master branch
  4. PUT the README.md file

The POST_REPO's payload contains payload.repo with information needed for API call 1.
The response from API call 1 is necessary for API call 2.
The response from API call 2 is necessary for API call 3.
The response from API call 3 and payload.file, which has information needed to update the README.md file, is neccessary for API call 4.

Using Observable.ForkJoin makes this possible.

Example

import { Injectable } from '@angular/core';
import { Effect, Actions } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { Scheduler } from 'rxjs/Scheduler';
import { of } from 'rxjs/observable/of';
import { handleError } from './handleError';


import { GithubService } from '../services/github.service';
import * as githubActions from '../actions/github';

@Injectable()
export class GitHubEffects {
  @Effect()
  postRepo$: Observable<Action> = this.actions$
    .ofType(githubActions.POST_REPO)
    .map((action: githubActions.PostRepo) => action.payload)
    // return the payload and POST the repo
    .switchMap((payload: any) => Observable.forkJoin([
      Observable.of(payload),
      this.githubService.postRepo(payload.repo)
    ]))
    // return the repo and the master branch as an array
    .switchMap((data: any) => {
      const [payload, repo] = data;
      return Observable.forkJoin([
        Observable.of(payload),
        Observable.of(repo),
        this.githubService.getMasterBranch(repo.name)
      ]);
    })
    // return the payload, the repo, and get the sha for README
    .switchMap((data: any) => {
      const [payload, repo, branch] = data;
      return Observable.forkJoin([
        Observable.of(payload),
        Observable.of(repo),
        this.githubService.getFiles(repo.name, branch)
          .map((files: any) => files.tree
            .filter(file => file.path === 'README.md')
            .map(file => file.sha)[0]
          )
      ]);
    })
    // update README with data from payload.file
    .switchMap((data: any) => {
      const [payload, repo, sha] = data;
      payload.file.sha = sha;
      return this.githubService.putFile(repo.name, payload.file);
    });

  constructor(
    private actions$: Actions,
    private githubService: GithubService,
  ) {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment