© Fu Cheng 2018
Fu ChengBuild Mobile Apps with Ionic 4 and Firebasehttps://doi.org/10.1007/978-1-4842-3775-5_6

6. State Management with NgRx

Fu Cheng1 
(1)
Sandringham, Auckland, New Zealand
 

When implementing the user story to list top stories in the last chapter, the actual data is stored in RxJS observables. Although the implementation in the ItemService is very concise, it’s not very easy to understand, especially the usage of different RxJS operators. The combination of RxJS operators can be very complicated, which makes the debugging and testing very hard. It’s better to use explicit state management in the app. We’ll use NgRx for state management.

The Importance of State Management

You may wonder why the concept of state management is introduced in this chapter. Let’s start with a brief history of web applications. In the early days of the Internet era, the web pages were very simple. They just displayed static content and offered very limited user interactions. With the prevalence of Web 2.0 ( https://en.wikipedia.org/wiki/Web_2.0 ), web apps have evolved to take on more responsibility. Some business logic has shifted from the server-side to the browser-side. A typical example is the Gmail web app. These kinds of web apps may have complicated page flows and offer rich user interactions. Since these web apps usually have only one web page, they are called Single Page Applications, or SPAs. Ionic apps are a typical example of SPAs.

After complicated logic is added to the front end, we need to manage the state inside of web apps. For typical SPAs, there are three kinds of states to manage.
  • Global state: This state contains global data shared by all pages, including system configurations and metadata. This state is usually populated from the server-side when the app is loaded and rarely changes during the whole life cycle of the app.

  • State to share between pages and components. This state is necessary for collaboration between different pages and components. States can be passed between different pages using the router, while components can share states using ancestor components or custom events.

  • Internal state for components. Some components may have complicated user interactions or back-end communications, so they need to manage their own state. For example, a complex form needs to enable/disable form controls based on user input or results of validation logic.

With all these kinds of states, we can manage them in an ad hoc fashion. For example, we can simply use global objects for the global state and use component properties for the internal state. This approach works well for simple projects, but for most real-world projects, it makes it hard for developers to figure out the data flow between different components. A state may be modified in many different places and have weird bugs related to time. Error diagnosis and bug fixing could be nightmares for future maintainers when dealing with tightly coupled components.

The trend is to use a global store as the single source of an app state. This is the Flux architecture ( https://facebook.github.io/flux/ ) promoted by Facebook. There are several libraries that implement this architecture or its variants, including Redux ( https://redux.js.org/ ) and NgRx used in this chapter.

Introduction to NgRx

NgRx ( https://github.com/ngrx/platform ) is a set of reactive libraries for Angular. It has several libraries for different purposes.
  • @ngrx/store – State management with RxJS for Angular applications.

  • @ngrx/effects – Side effects to model actions that may change an application state.

  • @ngrx/entity – Entity state adapter to manage records.

  • @ngrx/router-store – Integrate with Angular Router for @ngrx/store.

  • @ngrx/store-devtools – Dev tools for store instrumentation and time traveling debugging.

  • @ngrx/schematics – Scaffolding library for Angular applications.

We’ll use @ngrx/store, @ngrx/effects, @ngrx/entity, and @ngrx/store-devtools in this chapter. @ngrx/store is inspired by the popular library Redux. Below are the basic concepts to understand @ngrx/store.
  • Store – Store is a single immutable data structure that contains the whole state of the application.

  • Actions – The state stored in the store can only be changed by actions. An action has a mandatory type and an optional payload.

  • Reducers – Reducers are pure functions that change the state based on different actions. A reducer function takes the current state and the action as the input and returns the new state as the output. The output will become the new state and is the input of the next reducer function call.

  • Selectors  – Components use selectors to select the pieces of data from the whole state object for rendering.

When the application’s state is managed by the NgRx store, components use data from the state object in the store to render their views. Components can dispatch actions to the store based on user interactions. These actions run through all reducers and compute the next state in the store. The state changes trigger the updates in the components to render with the updated data. Store is the single source of truth of the application’s state. It’s also very clear to track the changes to the state as all changes must be triggered by actions. With the powerful time-traveling debugging support of the @ngrx/store-devtools, it’s possible to go back to the time after any previous action and view the state at that particular time. By checking the state, it’s much easier to find the cause of bugs.

With an internal state separated out from components, components are much easier to implement and test. The view of a component is only determined by its input properties. We can easily prepare different inputs to verify its view in unit tests.

The global state in the store can be viewed as a big object graph. It’s typical for large applications to divide the state object by features, where each feature manages one part of the whole state object. It’s a common practice to organize features as Angular modules. Components can select any data from the state object for rendering. When the selected state changes, components are re-rendered automatically.

As we mentioned before, actions trigger state changes by running reducers. There are other actions that may have side effects. For example, loading data from the server may dispatch new actions to the store. If the data is loaded successfully, a new action with loaded data is dispatched. However, if an error occurred during the loading, a new action with the error is dispatched. NgRx uses effects to handle these kinds of actions. Effects subscribe to all actions dispatched to the store and perform certain tasks when some types of actions are observed. The execution results of these tasks are actions that are also dispatched to the store. In the data loading example, the effect may use Angular HttpClient to perform the request and return different types of actions based on the response.

Use NgRx

Now we are going to update the implementation of the top stories page with NgRx.

The first step is to define the state object for the application. This is usually an iterative progress. Properties in the state object may be added and removed during the whole development life cycle. It’s typical to divide the state object by features. The state of each feature depends on the requirements of components. For the top stories page, we’ll have two features. The first feature items is for all loaded items. This is because items are shared by different pages in the application. The second feature topStories is for the top stories page itself. The state of this feature contains ids of all top stories and pagination data.

When using NgRx, we have a convention for directory structure. TypeScript files of actions, reducers, and effects are put into the directories actions, reducers, and effects, respectively. Each module may have its own set of actions, reducers, and effects. The final application state is composed of states from all the modules.

Items Feature

Let’s start from the state of the feature items.

Define State

As shown in Listing 6-1, the interface State is the state for the feature items . It’s defined in the file src/app/reducers/items.ts. It extends from EntityState in @ngrx/entity. You can think of EntityState as a database table. Each entity in the EntityState is corresponding to a row in the table and each entity must have a unique id. EntityState already defines two properties: ids and entities. The property ids contains all ids of the entities. The property entities is a map from entity ids to the actual entity objects. Two more properties are added to State. The property loading represents the loading status of items, while the property error contains errors if loading fails.
export interface State extends EntityState<Item> {
  loading: boolean;
  error: any;
}
Listing 6-1

State of the feature items

Create Actions

The state for items is easy to define. Now we move to the actions. As shown in Listing 6-2, we define three actions in the enum ItemActionTypes in the file src/app/actions/items.ts. The action Load means starting the loading of items; LoadSuccess means items are loaded successfully; LoadFail means items have failed to load. Actions must have a property type to specify their types. Types are used to distinguish different actions. Actions can also have optional payloads as the data sent with them. Actions in NgRx all implement the interface Action. The read-only property type is the type defined in the enum ItemActionTypes. If an action has a payload, the payload is defined as the only parameter in the constructor. The action Load has the payload of type number[] to specify the ids of items to load. LoadSuccess has the payload of type Item[] to specify the loaded items. The last action LoadFail has the payload type of any to specify the error that occurred during the loading. The type ItemActions is the union type of these three action types.
import { Action } from '@ngrx/store';
import { Item } from '../models/item';
export enum ItemActionTypes {
  Load = '[Items] Load',
  LoadSuccess = '[Items] Load Success',
  LoadFail = '[Items] Load Fail',
}
export class Load implements Action {
  readonly type = ItemActionTypes.Load;
  constructor(public payload: number[]) {}
}
export class LoadSuccess implements Action {
  readonly type = ItemActionTypes.LoadSuccess;
  constructor(public payload: Item[]) {}
}
export class LoadFail implements Action {
  readonly type = ItemActionTypes.LoadFail;
  constructor(public payload: any) {}
}
export type ItemActions = Load | LoadSuccess | LoadFail;
Listing 6-2

Actions of the feature items

Create Reducers

After defining the state and actions, we can create reducers. For each action defined, the reducer function needs to have logic to handle it. A reducer function takes two parameters: the first parameter is the current state; the second parameter is the action. The return result is the next state. If a reducer function doesn’t know how to handle an action, it should return the current state. When the reducer function runs for the first time, there is no current state. We also need to define the initial state.

As shown in Listing 6-3, because the feature state in Listing 6-1 extends from EntityState in @ngrx/entity, we can use the function createEntityAdapter to create an EntityAdapter object that has several helper functions to manage the state. To create the adapter, we need to provide an object with two properties: the property selectId is the function to extract the id from the entity object; sortComparer is the comparer function to sort the entities if sorting is required. Here we use false to disable the sorting. The function getInitialState creates the initial state for the EntityState with initial values for additional properties loading and error.

For the function reducer, the parameter state has the default value initialState. The type of the parameter action is the union type ItemActions defined in Listing 6-2, so only actions related to items are accepted. The actual logic in the reducer function depends on the type of actions. For the action Load, we update the property loading to true. For the action LoadSuccess, we use the function upsertMany provided by EntityAdapter to insert or update the loaded items. The property loading is also set to false to indicate that the loading is completed. The property error is set to null to clear any previous error. For action LoadingFail, we set the property error to be the error object in the action’s payload. The property loading is also set to false. States should be immutable, so the reducer function should always return a new state object.
export const adapter: EntityAdapter<Item> = createEntityAdapter<Item>({
  selectId: (item: Item) => item.id,
  sortComparer: false,
});
export const initialState: State = adapter.getInitialState({
  loading: false,
  error: null,
});
export function reducer(
  state = initialState,
  action: ItemActions,
): State {
  switch (action.type) {
    case ItemActionTypes.Load: {
      return {
        ...state,
        loading: true,
      };
    }
    case ItemActionTypes.LoadSuccess: {
      return adapter.upsertMany(action.payload, {
        ...state,
        loading: false,
        error: null,
      });
    }
    case ItemActionTypes.LoadFail: {
      return {
        ...state,
        loading: false,
        error: action.payload,
      };
    }
    default: {
      return state;
    }
  }
}
Listing 6-3

Reducer of items

Add Effects

The actual logic of loading items is implemented in the effect. In Listing 6-4, ItemEffects in the file src/app/effects/items.ts is a service with two dependencies. The object of type Actions represents the Observable of all actions dispatched to the store. Effects are observables of actions decorated with @Effect, so the type of effects is Observable<Action>. For the effect loadItems$, we use the function pipe to provide a series of operators to operate on the observable actions. The operator ofType is used to filter only actions of type Load; the operator map extracts items ids from the action payload; the operator combineLatest inside of the operator mergeMap combines Item objects for each item id and transforms them into action LoadSuccess or LoadFail. The logic to load items is similar to the ItemService.
@Injectable()
export class ItemsEffects {
  constructor(private actions$: Actions, private db: AngularFireDatabase) {}
  @Effect()
  loadItems$: Observable<Action> = this.actions$.pipe(
    ofType(ItemActionTypes.Load),
    map((action: Load) => action.payload),
    mergeMap((ids: number[]) =>
      combineLatest(
        ids.map(id => this.db.object('/v0/item/' + id).valueChanges().pipe(take(1)))
      ).pipe(
        map((items: Item[]) => new LoadSuccess(items)),
        catchError(error => of(new LoadFail(error))),
    ))
  );
}
Listing 6-4

Effects of items

With the effect loadItems$, the action Load triggers actions LoadSuccess or LoadFail.

Note

It’s a convention for variables of Observables to use $ as the name suffix.

Top Stories Feature

To load items, we need to dispatch actions Load with ids of items. These ids are loaded from the Hacker News API of top stories ids. We also need to manage the state of top stories ids.

Define the State

The State in Listing 6-5 is similar to the state for items in Listing 6-1. The property ids represents ids of all top stories, while properties loading and error are for loading and error status, respectively.
export interface State {
  ids: number[];
  loading: boolean;
  error?: any;
}
Listing 6-5

State of top stories

Create Actions

We also have actions defined for top stories ids; see Listing 6-6. The actions LoadSuccess and LoadFail in the enum TopStoriesActionTypes have the same meanings as in the enum ItemActionTypes. Based on the requirements, there are two types of loading for top stories. The first type is to load all top stories ids from the API, which is specified as the action Refresh; the second type is to load more stories ids that have been retrieved, which is specified as the action LoadMore. The action Refresh is triggered when the user is pulling down the list to refresh, while the action LoadMore is triggered when the user is scrolling the list to view more stories. The payload of each action is very easy to understand.
import { Action } from '@ngrx/store';
export enum TopStoriesActionTypes {
  Refresh = '[Top Stories] Refresh',
  LoadMore = '[Top Stories] Load More',
  LoadSuccess = '[Top Stories] Load Success',
  LoadFail = '[Top Stories] Load Fail',
}
export class Refresh implements Action {
  readonly type = TopStoriesActionTypes.Refresh;
}
export class LoadMore implements Action {
  readonly type = TopStoriesActionTypes.LoadMore;
}
export class LoadSuccess implements Action {
  readonly type = TopStoriesActionTypes.LoadSuccess;
  constructor(public payload: number[]) {}
}
export class LoadFail implements Action {
  readonly type = TopStoriesActionTypes.LoadFail;
  constructor(public payload: any) {}
}
export type TopStoriesActions = Refresh | LoadMore | LoadSuccess | LoadFail;
Listing 6-6

Actions of top stories

Create Reducers

The reducer function of top stories ids is shown in Listing 6-7. The implementation is straightforward. The action LoadMore is not included in this reducer function. This is because the action LoadMore doesn’t have an effect on the state of top stories ids. It will be processed in the reducer function for pagination.
const initialState: State = {
  ids: [],
  loading: false,
  error: null,
};
export function reducer(
  state = initialState,
  action: TopStoriesActions,
): State {
  switch (action.type) {
    case TopStoriesActionTypes.Refresh:
      return {
        ...state,
        loading: true,
      };
    case TopStoriesActionTypes.LoadSuccess:
      return {
        loading: false,
        ids: action.payload,
        error: null,
      };
    case TopStoriesActionTypes.LoadFail:
      return {
        ...state,
        loading: false,
        error: action.payload,
      };
    default: {
      return state;
    }
  }
}
Listing 6-7

Reducer of top stories

Pagination

The top stories page needs to maintain the pagination state. The state and reducer function for pagination is shown in Listing 6-8. The state has three properties: offset, limit, and total. The page size is set to 10. For the action Refresh, the property offset is set to 0; for the action LoadMore, the property offset is increased by the page size; for the action LoadSuccess, the property total is updated with the number of stories.
export const pageSize = 10;
const initialState: State = {
  offset: 0,
  limit: pageSize,
  total: 0,
};
export function reducer(
  state = initialState,
  action: TopStoriesActions,
): State {
  switch (action.type) {
    case TopStoriesActionTypes.Refresh:
      return {
        ...state,
        offset: 0,
        limit: pageSize,
      };
    case TopStoriesActionTypes.LoadMore:
      const offset = state.offset + state.limit;
      return {
        ...state,
        offset: offset < state.total ? offset : state.offset,
      };
    case TopStoriesActionTypes.LoadSuccess:
      return {
        ...state,
        total: action.payload.length,
      };
    default: {
      return state;
    }
  }
}
Listing 6-8

State and reducer function for pagination

Add Effects

The last piece is the effect to load ids of top stories. Listing 6-9 shows the effect TopStoriesEffects. Comparing to ItemsEffects in Listing 6-4, the constructor of TopStoriesEffects has an extra parameter of type Store<fromTopStories.State>. This is because TopStoriesEffects needs to access the state in the store to perform its tasks. The type fromTopStories.State describes the state for the module top stories. TopStoriesEffects has two effects. The first effect loadTopStories$ is responsible for loading ids of top stories from Hacker News API. The loading is triggered by the action TopStoriesActionTypes.Refresh. When the loading succeeds, two actions are dispatched to the store. The first action is the topStoriesActions.LoadSuccess shown in Listing 6-6, and the second action is the itemActions.Load shown in Listing 6-2 to trigger the loading of items in the first page. The second effect loadMore$ is responsible for triggering the loading of items of the action TopStoriesActionTypes.LoadMore. This effect uses the operator withLatestFrom to get the current state from the store, then extracts ids of top stories and pagination status from the state, and finally dispatches the action itemActions.Load. The Store in NgRx is also an Observable object. This is the first time we see the structure of the global state object. We’ll discuss the structure soon. Effects are only executed after actions run through all reducers. When the effect loadMore$ is executed, the reducer function in Listing 6-8 already updates the pagination state to the next page.
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Action, Store } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { TopStoriesActionTypes } from '../actions/top-stories';
import { catchError, map, mergeMap, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { AngularFireDatabase } from '@angular/fire/database';
import * as fromTopStories from '../reducers';
import { pageSize } from '../reducers/pagination';
import * as itemActions from '../actions/items';
import * as topStoriesActions from '../actions/top-stories';
@Injectable()
export class TopStoriesEffects {
  constructor(private actions$: Actions,
              private store: Store<fromTopStories.State>,
              private db: AngularFireDatabase) {}
  @Effect()
  loadTopStories$: Observable<Action> = this.actions$.pipe(
    ofType(TopStoriesActionTypes.Refresh),
    switchMap(() =>
      this.db.list('/v0/topstories').valueChanges()
        .pipe(
          take(1),
          mergeMap((ids: number[]) => of<Action>(
            new topStoriesActions.LoadSuccess(ids),
            new itemActions.Load(ids.slice(0, pageSize)))),
          catchError(error => of(new topStoriesActions.LoadFail(error))),
        )
    )
  );
  @Effect()
  loadMore$: Observable<Action> = this.actions$.pipe(
    ofType(TopStoriesActionTypes.LoadMore),
    withLatestFrom(this.store),
    map(([action, state]) => {
      const {
        pagination: {
          offset,
          limit,
        },
        stories: {
          ids,
        }
      } = state.topStories;
      return new itemActions.Load(ids.slice(offset, offset + limit));
    })
  );
}
Listing 6-9

Effects of top stories

The reducer functions in Listings 6-3, 6-7, and 6-8 only deal with a subset of the global state. The global state is a hierarchical composition of states at different levels. It’s common to have the file index.ts in directory reducers to compose the state for a module. Listing 6-10 shows the state and reducers of the top stories module. The interface TopStoriesState is the composite of state with states from ids of top stories and pagination. The interface State includes states from the feature items and TopStoriesState. For TopStoriesState, fromTopStories.State, and fromPagination.State both have their reducer functions. ActionReducerMap is the map from states to reducer functions, which is commonly used to combine states and corresponding reducers.
import * as fromRoot from '../../reducers';
import * as fromTopStories from './top-stories';
import * as fromPagination from './pagination';
import * as fromItems from './items';
export interface TopStoriesState {
  stories: fromTopStories.State;
  pagination: fromPagination.State;
}
export interface State extends fromRoot.State {
  items: fromItems.State;
  topStories: TopStoriesState;
}
export const reducers: ActionReducerMap<TopStoriesState> = {
  stories: fromTopStories.reducer,
  pagination: fromPagination.reducer,
};
Listing 6-10

State and reducers of top stories

Selectors

After finishing the states, actions, and reducers, we need to update the components to interact with the store. Components get data from the store for the view and may dispatch actions to the store. A component is usually interested in a subset of the global state. We use the selectors provided by NgRx to select states from the store. Selectors are functions that define how to extract data from the state. Selectors can be chained together to reuse the selection logic. One important characteristic of selector functions is that they memorize results to avoid unnecessary computations. Selector functions are typically defined in the same file as the reducer functions.

Listing 6-11 shows the selector functions for the feature items. The function getItemsState is the selector to select the root state for the feature. It’s created using the function createFeatureSelector from NgRx. The state of the feature items uses the EntityState from @ngrx/entity. We can use the adapter created in Listing 6-3 to create selectors. The selector getItemEntities selects the entities map from the state. The selectors getLoading and getError select the property loading and error from the state, respectively.
export const getItemsState = createFeatureSelector<State>('items');
export const {
  selectEntities: getItemEntities,
} = adapter.getSelectors(getItemsState);
export const getLoading = (state: State) => state.loading;
export const getError = (state: State) => state.error;
Listing 6-11

Selectors of the feature items

We also create selectors for ids of top stories; see Listing 6-12. These three selectors, getIds, getLoading, and getError are very easy to understand.
export const getIds = (state: State) => state.ids;
export const getLoading = (state: State) => state.loading;
export const getError = (state: State) => state.error;
Listing 6-12

Selectors of top stories

With these simple selectors in Listings 6-11 and 6-12, we can define complicated selectors to use in components; see Listing 6-13.
  • getTopStoriesState selects the root state of the feature topStories.

  • getPaginationState selects the state of pagination.

  • getStoriesState selects the state of ids of top stories.

  • getStoryIds selects ids of top stories.

  • getDisplayItems selects the items to display by combining the results from getStoryIds, getItemEntities, and getPaginationState.

  • isItemsLoading selects the loading state of items.

  • getItemsError selects the error of loading items.

  • isTopStoriesLoadings selects the loading state of ids of top stories.

  • getTopStoriesError selects the error of loading ids.

  • getError selects the first error that occurred when loading items or ids.

The function createSelector chains selectors together to get the final result. For a selector, if the upstream selectors changed, it will be reevaluated.
export const getTopStoriesState = createFeatureSelector<TopStoriesState>('topStories');
export const getPaginationState = createSelector(
  getTopStoriesState,
  state => state.pagination,
);
export const getStoriesState = createSelector(
  getTopStoriesState,
  state => state.stories,
);
export const getStoryIds = createSelector(
  getStoriesState,
  fromTopStories.getIds,
);
export const getDisplayItems = createSelector(
  getStoryIds,
  getItemEntities,
  getPaginationState,
  (ids, entities, pagination) => {
    return {
      results: ids.slice(0, pagination.offset + pagination.limit).map(id => entities[id]),
    };
  }
);
export const isItemsLoading = createSelector(
  getItemsState,
  fromItems.getLoading,
);
export const getItemsError = createSelector(
  getItemsState,
  fromItems.getError,
);
export const isTopStoriesLoading = createSelector(
  getStoriesState,
  fromTopStories.getLoading,
);
export const getTopStoriesError = createSelector(
  getStoriesState,
  fromTopStories.getError,
);
export const getError = createSelector(
  getTopStoriesError,
  getItemsError,
  (e1, e2) => e1 || e2,
);
Listing 6-13

Selectors for components

Update Components

Now we can update the TopStoriesComponent to use NgRx; see Listing 6-14. The implementation is completely different from the previous one using ItemService. TopStoriesComponent is injected with the Store from NgRx. TopStoriesComponent defines different Observables using different selectors. The observable items$ created by store.pipe(select(fromTopStories.getDisplayItems)) uses the selector getDisplayItems to select the items to display; itemsLoading$ represents the loading state of items; idsLoading$ represents the loading state of ids of top stories; errors$ represents errors. For the values in itemsLoading$, the function notifyScrollComplete is invoked to notify ion-infinite-scroll to complete. For the values in idsLoading$, functions showLoading or hideLoading are invoked to show or hide the loading spinner. For the values in errors$, the function showError is invoked to show the toast displaying error messages. The function doLoad dispatches the action topStoriesActions.Refresh or topStoriesActions.LoadMore depends on the type of loading.

TopStoriesComponent also uses the change detection strategy ChangeDetectionStrategy.OnPush to trigger the UI changes more efficiently.
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { from, Observable, Subscription } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { Items } from '../models/items';
import { LoadingController, ToastController } from '@ionic/angular';
import * as fromTopStories from './reducers';
import * as topStoriesActions from './actions/top-stories';
import { filter, concatMap } from 'rxjs/operators';
@Component({
  selector: 'app-top-stories',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './top-stories.component.html',
  styleUrls: ['./top-stories.component.scss']
})
export class TopStoriesComponent implements OnInit, OnDestroy {
  items$: Observable<Items>;
  private itemsLoading$: Observable<boolean>;
  private idsLoading$: Observable<boolean>;
  private errors$: Observable<any>;
  private infiniteScrollComponent: any;
  private refresherComponent: any;
  private loading: HTMLIonLoadingElement;
  private subscriptions: Subscription[];
  constructor(private store: Store<fromTopStories.State>,
              private loadingCtrl: LoadingController,
              private toastCtrl: ToastController) {
    this.items$ = store.pipe(select(fromTopStories.getDisplayItems));
    this.itemsLoading$ = store.pipe(select(fromTopStories.isItemsLoading));
    this.idsLoading$ = store.pipe(select(fromTopStories.isTopStoriesLoading));
    this.errors$ = store.pipe(select(fromTopStories.getError), filter(error => error != null));
    this.subscriptions = [];
  }
  ngOnInit() {
    this.subscriptions.push(this.itemsLoading$.subscribe(loading => {
      if (!loading) {
        this.notifyScrollComplete();
      }
    }));
    this.subscriptions.push(this.idsLoading$.pipe(concatMap(loading => {
      return loading ? from(this.showLoading()) : from(this.hideLoading());
    })).subscribe());
    this.subscriptions.push(this.errors$.pipe(concatMap(error => from(this.showError(error)))).subscribe());
    this.doLoad(true);
  }
  ngOnDestroy(): void {
    this.subscriptions.forEach(subscription => subscription.unsubscribe());
  }
  load(event) {
    this.infiniteScrollComponent = event.target;
    this.doLoad(false);
  }
  refresh(event) {
    this.refresherComponent = event.target;
    this.doLoad(true);
  }
  doLoad(refresh: boolean) {
    if (refresh) {
      this.store.dispatch(new topStoriesActions.Refresh());
    } else {
      this.store.dispatch(new topStoriesActions.LoadMore());
    }
  }
  private notifyScrollComplete(): void {
    if (this.infiniteScrollComponent) {
      this.infiniteScrollComponent.complete();
    }
  }
  private notifyRefreshComplete(): void {
    if (this.refresherComponent) {
      this.refresherComponent.complete();
    }
  }
  private showLoading(): Promise<void> {
    return this.hideLoading().then(() => {
      return this.loadingCtrl.create({
        content: 'Loading...',
      }).then(loading => {
        this.loading = loading;
        return this.loading.present();
      });
    });
  }
  private hideLoading(): Promise<void> {
    if (this.loading) {
      this.notifyRefreshComplete();
      return this.loading.dismiss().then(() => null);
    }
    return Promise.resolve();
  }
  private showError(error: any): Promise<void> {
    return this.toastCtrl.create({
      message: `An error occurred: ${error}`,
      duration: 3000,
      showCloseButton: true,
    }).then(toast => toast.present());
  }
}
Listing 6-14

Updated TopStoriesComponent using NgRx

After using Observable<Items> as the type of items, we need to update the template as below.
<app-items [items]="items$ | async"></app-items>
To enable NgRx for the top stories module, we need to import some modules in TopStoriesModule. In Listing 6-15 of the file top-stories.module.ts, StoreModule.forFeature('topStories', topStoriesReducers) creates the module for the feature topStories with the reducers map defined in Listing 6-10. EffectsModule.forFeature([TopStoriesEffects, ItemsEffects]) creates the module for effects.
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TopStoriesRoutingModule } from './top-stories-routing.module';
import { TopStoriesComponent } from './top-stories.component';
import { reducers as topStoriesReducers } from './reducers';
import { StoreModule } from '@ngrx/store';
import { TopStoriesEffects } from './effects/top-stories';
import { EffectsModule } from '@ngrx/effects';
@NgModule({
  imports: [
    CommonModule,
    TopStoriesRoutingModule,
    StoreModule.forFeature('topStories', topStoriesReducers),
    EffectsModule.forFeature([TopStoriesEffects]),
  ],
  declarations: [TopStoriesComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class TopStoriesModule { }
Listing 6-15

Import NgRx modules

NgRx modules for the feature items will be added to the root module. There are other NgRx modules that need to be imported in the root module. Listing 6-16 shows the code added to the file app.module.ts. Here we use StoreModule.forRoot(reducers) and EffectsModule.forRoot([ItemsEffects]) for the root module. StoreDevtoolsModule.instrument is used to import the module of NgRx devtools from @ngrx/store-devtools.
import { reducers } from './reducers';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { ItemsEffects } from './effects/items';
@NgModule({
  declarations: [
    MyApp,
  ],
  imports: [
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument({
      name: 'NgRx HNC DevTools',
      logOnly: environment.production,
    }),
    EffectsModule.forRoot([ItemsEffects]),
  ],
})
export class AppModule {}
Listing 6-16

Import NgRx modules in the root module

Unit Testing

We need to add unit tests for reducer functions and effects. Unit tests for reducer functions are easy to write, as reducer functions are pure functions. We just need to create the current state object, then call the reducer function with actions, and finally verify the result state. Listing 6-17 is the example of testing a reducer function for the feature items. In this test spec, we create three mock items: item1, item2, and item3. The initial state contains item1 and item2. In the first spec, after a Load action, the property loading should be true. In the second spec, the action is LoadSuccess with item3 as the payload, and item3 should be added to the entities. In the third spec, the action is LoadFail with an error object, and the property error should match the action’s payload. The last spec verifies the selector to get the loading state.
import * as fromItems from './items';
import { reducer } from './items';
import { Load, LoadFail, LoadSuccess } from '../actions/items';
import { createMockItem } from '../models/item';
describe('ItemsReducer', () => {
  const item1 = createMockItem(1);
  const item2 = createMockItem(2);
  const item3 = createMockItem(3);
  const initialState: fromItems.State = {
    ids: [item1.id, item2.id],
    entities: {
      [item1.id]: item1,
      [item2.id]: item2,
    },
    loading: false,
    error: null,
  };
  it('should set the loading state', () => {
    const action = new Load([item3.id]);
    const result = reducer(initialState, action);
    expect(result.loading).toBe(true);
  });
  it('should set the loaded items', () => {
    const action = new LoadSuccess([item3]);
    const result = reducer(initialState, action);
    expect(result.loading).toBe(false);
    expect(result.ids).toContain(item3.id);
    expect(result.entities[item3.id]).toEqual(item3);
  });
  it('should set the fail state', () => {
    const error = new Error('load fail');
    const action = new LoadFail(error);
    const result = reducer(initialState, action);
    expect(result.loading).toBe(false);
    expect(result.error).toEqual(error);
  });
  it('should select the the loading state', () => {
    const result = fromItems.getLoading(initialState);
    expect(result).toBe(false);
  });
});
Listing 6-17

Unit tests of the reducer function

Testing effects is harder than reducer functions. Listing 6-18 is the unit test for ItemsEffects. To test this effect, we need to provide mocks for services Actions and AngularFireDatabase. TestActions is the mock for Actions that allows setting the dispatched actions, while dbMock is a mock object for AngularFireDatabase with the property object as a Jasmine spy. This is because ItemsEffects uses the method object of AngularFireDatabase, so we need to provide mock implementation for this method.

In the first test spec for the success case, the dispatched action is a Load action with two ids. The expected action is a LoadSuccess with two loaded items. The Jasmine spy for the method object is updated to return different items based on the ids to load. The Jasmine function callFake provides the implementation of the spied method. Here we use the library jasmine-marbles ( https://www.npmjs.com/package/jasmine-marbles ) to easily create observables with specified values. For example, hot('-a', { a: action }) creates a hot observable that emits the action, cold('-b', { b: item }) creates a cold observable that emits the item. The second test spec is similar to the first one, expecting that the Jasmine spy returns an Observable with error when loading items, so the expected action is a LoadFail with the provided error as the payload.
import { Actions } from '@ngrx/effects';
import { AngularFireDatabase } from '@angular/fire/database';
import { EMPTY, Observable } from 'rxjs';
import { cold, hot } from 'jasmine-marbles';
import { TestBed } from '@angular/core/testing';
import { ItemsEffects } from './items';
import { Load, LoadFail, LoadSuccess } from '../actions/items';
import { createMockItem } from '../models/item';
export class TestActions extends Actions {
  constructor() {
    super(EMPTY);
  }
  set stream(source: Observable<any>) {
    this.source = source;
  }
}
export function getActions() {
  return new TestActions();
}
describe('ItemsEffects', () => {
  let db: any;
  let effects: ItemsEffects;
  let actions$: TestActions;
  const item1 = createMockItem(1);
  const item2 = createMockItem(2);
  beforeEach(() => {
    const dbMock = {
      object: jasmine.createSpy('object'),
    };
    TestBed.configureTestingModule({
      providers: [
        ItemsEffects,
        { provide: Actions, useFactory: getActions },
        { provide: AngularFireDatabase, useValue: dbMock },
      ],
    });
    db = TestBed.get(AngularFireDatabase);
    effects = TestBed.get(ItemsEffects);
    actions$ = TestBed.get(Actions);
  });
  describe('loadItem$', () => {
    it('should return a LoadSuccess with items, on success', () => {
      const action = new Load([1, 2]);
      const completion = new LoadSuccess([item1, item2]);
      actions$.stream = hot('-a', { a: action });
      db.object = jasmine.createSpy('object').and.callFake(path => {
        const id = parseInt(//v0/item/(d+)/.exec(path)[1], 10);
        const item = id === 1 ? item1 : item2;
        return {
          valueChanges: () => cold('-b', { b: item }),
        };
      });
      const expected = cold('--c', { c: completion });
      expect(effects.loadItems$).toBeObservable(expected);
    });
    it('should return a LoadFail with error, on error', () => {
      const action = new Load([1, 2]);
      const error = 'Error';
      const completion = new LoadFail(error);
      actions$.stream = hot('-a', { a: action });
      db.object = jasmine.createSpy('object').and.callFake(path => {
        return {
          valueChanges: () => cold('-#', {}, error),
        };
      });
      const expected = cold('--c', { c: completion });
      expect(effects.loadItems$).toBeObservable(expected);
    });
  });
});
Listing 6-18

Unit test of effects

Use @ngrx/store-devtools

We already configured @ngrx/store-devtools in the app module. To view the state and actions in the store, we need to install the Redux Devtools Extension ( https://github.com/zalmoxisus/redux-devtools-extension/ ) on Chrome or Firefox. This extension provides powerful features for debugging. Figure 6-1 shows the screenshot of the extension.
../images/436854_2_En_6_Chapter/436854_2_En_6_Fig1_HTML.jpg
Figure 6-1

Use Redux Devtools Extension

Summary

State management is an important part of any nontrivial applications. With the prevalence of Redux, developers are already familiar with concepts related to state management. NgRx is the best choice for state management of Angular applications. This chapter covers concepts like state, actions, reducers, effects, and selectors in NgRx and updates the top stories page to use NgRx to manage the state. In the next chapter, we’ll implement the user story to view web pages of top stories.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.133.161.153