19. NgRx

19.1. Introduction to NgRx

NgRx is a state management framework for Angular based on the Redux pattern.

19.1.1. The need for client side state management

You may wonder why you should bother with state management. Usually data resides in a backend storage system, e.g. a database, and is retrieved by the client on a per-need basis. To add, update, or delete entities from this store, clients have to invoke API endpoints at the backend. Mimicking database-like transactions on the client side may seem redundant. However, there are many use cases for which a global client-side state is appropriate:

  • the client has some kind of global state which should survive the destruction of a component, but does not warrant server side persistence, for example: volume level of media, expansion status of menus

  • sever side data should not be retrieved every time it is needed, either because multiple components consume it, or because it should be cached, e.g. the personal watchlist in an online streaming app

  • the app provides a rich experience with offline functionality, e.g. a native app built with Ionic

Saving global states inside the services they originates from results in a data flow that is hard to follow and state becoming inconsistent due to unordered state mutations. Following the single source of truth principle, there should be a central location holding all your application’s state, just like a server side database does. State managament libraries for Angular provide tools for storing, retrieving, and updating client-side state.

19.1.2. Why NgRx?

As stated in the introduction, devon4ng does not stipulate a particular state library, or require using one at all. However, NgRx has proven to be a robust, mature solution for this task, with good tooling and 3rd-party library support. Albeit introducing a level of indirection that requires additional effort even for simple features, the redux concept enforces a clear separation of concerns leading to a cleaner architecture.

Nonetheless, you should always compare different approaches to state management and pick the best one suiting your use case. Here’s a (non-exhaustive) list of competing state management libraries:

  • Plain Rxjs using the simple store described in Abstract Class Store

  • NgXS reduces some boilerplate of NgRx by leveraging the power of decorators and moving side effects to the store

  • MobX follows a more imperative approach in contrast to the functional Redux pattern

  • Akita also uses an imperative approach with direct setters in the store, but keeps the concept of immutable state transitions

19.1.3. Setup

To get a quick start, use the provided template for devon4ng + NgRx.

To manually install the core store package together with a set of useful extensions:

NPM:

npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools --save

Yarn:

yarn add @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools

We recommend to add the NgRx schematics to your project so you can create code artifacts from the command line:

NPM:

npm install @ngrx/schematics --save-dev

Yarn:

yarn add @ngrx/schematics --dev

Afterwards, make NgRx your default schematics provider, so you don’t have to type the qualified package name every time:

ng config cli.defaultCollection @ngrx/schematics

If you have custom settings for Angular schematics, you have to configure them as described here.

19.1.4. Concept

NgRx Architecture
Figure 82. NgRx architecture overview

Figure 1 gives an overview of the NgRx data flow. The single source of truth is managed as an immutable state object by the store. Components dispatch actions to trigger state changes. Actions are handed over to reducers, which take the current state and action data to compute the next state. Actions are also consumed byeffects, which perform side-effects such as retrieving data from the backend, and may dispatch new actions as a result. Components subscribe to state changes using selectors.

Continue with Creating a Simple Store.

19.2. State, Selection and Reducers

19.2.1. Creating a Simple Store

In the following pages we use the example of an online streaming service. We will model a particular feature, a watchlist that can be populated by the user with movies she or he wants to see in the future.

Initializing NgRx

If you’re starting fresh, you first have to initialize NgRx and create a root state. The fastest way to do this is using the schematic:

ng generate @ngrx/schematics:store State --root --module app.module.ts

This will automatically generate a root store and register it in the app module. Next we generate a feature module for the watchlist:

ng generate module watchlist

and create a corresponding feature store:

ng generate store watchlist/Watchlist -m watchlist.module.ts

This generates a file watchlist/reducers/index.ts with the reducer function, and registers the store in the watchlist module declaration.

Warning

If you’re getting an error Schematic "store" not found in collection "@schematics/angular", this means you forgot to register the NgRx schematics as default.

Next, add the WatchlistModule to the AppModule imports so the feature store is registered when the application starts. We also added the store devtools which we will use later, resulting in the following file:

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { EffectsModule } from '@ngrx/effects';
import { AppEffects } from './app.effects';
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './reducers';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';
import { WatchlistModule } from './watchlist/watchlist.module';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    WatchlistModule,
    StoreModule.forRoot(reducers, { metaReducers }),
    // Instrumentation must be imported after importing StoreModule (config is optional)
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
      logOnly: environment.production, // Restrict extension to log-only mode
    }),
    !environment.production ? StoreDevtoolsModule.instrument() : []
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Create an entity model and initial state

We need a simple model for our list of movies. Create a file watchlist/models/movies.ts and insert the following code:

export interface Movie {
    id: number;
    title: string;
    releaseYear: number;
    runtimeMinutes: number;
    genre: Genre;
}

export type Genre = 'action' | 'fantasy' | 'sci-fi' | 'romantic' | 'comedy' | 'mystery';

export interface WatchlistItem {
    id: number;
    movie: Movie;
    added: Date;
    playbackMinutes: number;
}
Note

We discourage putting several types into the same file and do this only for the sake of keeping this tutorial brief.

Later we will learn how to retrieve data from the backend using effects. For now we will create an initial state for the user with a default movie.

State is defined and transforms by a reducer function. Let’s create a watchlist reducer:

cd watchlist/reducers
ng g reducer WatchlistData --reducers index.ts

Open the generated file watchlist-data.reducer.ts. You see three exports: The State interface defines the shape of the state. There is only one instance of a feature state in the store at all times. The initialState constant is the state at application creation time. The reducer function will later be called by the store to produce the next state instance based on the current state and an action object.

Let’s put a movie into the user’s watchlist:

watchlist-data.reducer.ts

export interface State {
  items: WatchlistItem[];
}

export const initialState: State = {
  items: [
    {
      id: 42,
      movie: {
        id: 1,
        title: 'Die Hard',
        genre: 'action',
        releaseYear: 1988,
        runtimeMinutes: 132
      },
      playbackMinutes: 0,
      added: new Date(),
    }
  ]
};
Select the current watchlist

State slices can be retrieved from the store using selectors.

Create a watchlist component:

ng g c watchlist/Watchlist

and add it to the exports of WatchlistModule. Also, replace app.component.html with

<app-watchlist></app-watchlist>

State observables are obtained using selectors. They are memoized by default, meaning that you don’t have to worry about performance if you use complicated calculations when deriving state — these are only performed once per state emission.

Add a selector to watchlist-data.reducer.ts:

export const getAllItems = (state: State) => state.items;

Next, we have to re-export the selector for this substate in the feature reducer. Modify the watchlist/reducers/index.ts like this:

watchlist/reducers/index.ts

import {
  ActionReducer,
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
  MetaReducer
} from '@ngrx/store';
import { environment } from 'src/environments/environment';
import * as fromWatchlistData from './watchlist-data.reducer';
import * as fromRoot from 'src/app/reducers/index';

export interface WatchlistState { (1)
  watchlistData: fromWatchlistData.State;
}

export interface State extends fromRoot.State { (2)
  watchlist: WatchlistState;
}

export const reducers: ActionReducerMap<WatchlistState> = { (3)
  watchlistData: fromWatchlistData.reducer,
};

export const metaReducers: MetaReducer<WatchlistState>[] = !environment.production ? [] : [];

export const getFeature = createFeatureSelector<State, WatchlistState>('watchlist'); (4)

export const getWatchlistData = createSelector( (5)
  getFeature,
  state => state.watchlistData
);

export const getAllItems = createSelector( (6)
  getWatchlistData,
  fromWatchlistData.getAllItems
);
  1. The feature state, each member is managed by a different reducer

  2. Feature states are registered by the forFeature method. This interface provides a typesafe path from root to feature state.

  3. Tie substates of a feature state to the corresponding reducers

  4. Create a selector to access the 'watchlist' feature state

  5. select the watchlistData sub state

  6. re-export the selector

Note how createSelector allows to chain selectors. This is a powerful tool that also allows for selecting from multiple states.

You can use selectors as pipeable operators:

watchlist.component.ts

export class WatchlistComponent {
  watchlistItems$: Observable<WatchlistItem[]>;

  constructor(
    private store: Store<fromWatchlist.State>
  ) {
    this.watchlistItems$ = this.store.pipe(select(fromWatchlist.getAllItems));
  }
}

watchlist.component.html

<h1>Watchlist</h1>
<ul>
    <li *ngFor="let item of watchlistItems$ | async">{{item.movie.title}} ({{item.movie.releaseYear}}): {{item.playbackMinutes}}/{{item.movie.runtimeMinutes}} min watched</li>
</ul>
Dispatching an action to update watched minutes

We track the user’s current progress at watching a movie as the playbackMinutes property. After closing a video, the watched minutes have to be updated. In NgRx, state is being updated by dispatching actions. An action is an option with a (globally unique) type discriminator and an optional payload.

Creating the action

Create a file playback/actions/index.ts. In this example, we do not further separate the actions per sub state. Actions can be defined by using action creators:

playback/actions/index.ts

import { createAction, props, union } from '@ngrx/store';

export const playbackFinished = createAction('[Playback] Playback finished', props<{ movieId: number, stoppedAtMinute: number }>());

const actions = union({
    playbackFinished
});

export type ActionsUnion = typeof actions;

First we specify the type, followed by a call to the payload definition function. Next, we create a union of all possible actions for this file using union, which allows us a to access action payloads in the reducer in a typesafe way.

Tip

Action types should follow the naming convention [Source] Event, e.g. [Recommended List] Hide Recommendation or [Auth API] Login Success. Think of actions rather as events than commands. You should never use the same action at two different places (you can still handle multiple actions the same way). This faciliates tracing the source of an action. For details see Good Action Hygiene with NgRx by Mike Ryan (video).

Dispatch

We skip the implementation of an actual video playback page and simulate wathcing a movie in 10 minute segments by adding a link in the template:

watchlist-component.html

<li *ngFor="let item of watchlistItems$ | async">... <button (click)="stoppedPlayback(item.movie.id, item.playbackMinutes + 10)">Add 10 Minutes</button></li>

watchlist-component.ts

import * as playbackActions from 'src/app/playback/actions';
...
  stoppedPlayback(movieId: number, stoppedAtMinute: number) {
    this.store.dispatch(playbackActions.playbackFinished({ movieId, stoppedAtMinute }));
  }
State reduction

Next, we handle the action inside the watchlistData reducer. Note that actions can be handled by multiple reducers and effects at the same time to update different states, for example if we’d like to show a rating modal after playback has finished.

watchlist-data.reducer.ts

export function reducer(state = initialState, action: playbackActions.ActionsUnion): State {
  switch (action.type) {
    case playbackActions.playbackFinished.type:
      return {
        ...state,
        items: state.items.map(updatePlaybackMinutesMapper(action.movieId, action.stoppedAtMinute))
      };

    default:
      return state;
  }
}

export function updatePlaybackMinutesMapper(movieId: number, stoppedAtMinute: number) {
  return (item: WatchlistItem) => {
    if (item.movie.id === movieId) {
      return {
        ...item,
        playbackMinutes: stoppedAtMinute
      };
    } else {
      return item;
    }
  };
}

Note how we changed the reducer’s function signature to reference the actions union. The switch-case handles all incoming actions to produce the next state. The default case handles all actions a reducer is not interested in by returning the state unchanged. Then we find the watchlist item corresponding to the movie with the given id and update the playback minutes. Since state is immutable, we have to clone all objects down to the one we would like to change using the object spread operator (…​).

Caution

Selectors rely on object identity to decide whether the value has to be recalculated. Do not clone objects that are not on the path to the change you want to make. This is why updatePlaybackMinutesMapper returns the same item if the movie id does not match.

Alternative state mapping with immer

It can be hard to think in immutable changes, especially if your team has a strong background in imperative programming. In this case, you may find the immer library convenient, which allows to produce immutable objects by manipulating a proxied draft. The same reducer can then be written as:

watchlist-data.reducer.ts with immer

import { produce } from 'immer';
...
case playbackActions.playbackFinished.type:
      return produce(state, draft => {
        const itemToUpdate = draft.items.find(item => item.movie.id === action.movieId);
        if (itemToUpdate) {
          itemToUpdate.playbackMinutes = action.stoppedAtMinute;
        }
      });

Immer works out of the box with plain objects and arrays.

Redux devtools

If the StoreDevToolsModule is instrumented as described above, you can use the browser extension Redux devtools to see all dispatched actions and the resulting state diff, as well as the current state, and even travel back in time by undoing actions.

Redux Devtools
Figure 83. Redux devtools

Continue with learning about effects

19.3. Side effects with NgRx/Effects

Reducers are pure functions, meaning they are side-effect free and deterministic. Many actions however have side effects like sending messages or displaying a toast notification. NgRx encapsulates these actions in effects.

Let’s build a recommended movies list so the user can add movies to their watchlist.

19.3.1. Obtaining the recommendation list from the server

Create a module for recommendations and add stores and states as in the previous chapter. Add EffectsModule.forRoot([]) to the imports in AppModule below StoreModule.forRoot(). Add effects to the feature module:

ng generate effect recommendation/Recommendation -m recommendation/recommendation.module.ts

We need actions for loading the movie list, success and failure cases:

recommendation/actions/index.ts

import { createAction, props, union } from '@ngrx/store';
import { Movie } from 'src/app/watchlist/models/movies';

export const loadRecommendedMovies = createAction('[Recommendation List] Load movies');
export const loadRecommendedMoviesSuccess = createAction('[Recommendation API] Load movies success', props<{movies: Movie[]}>());
export const loadRecommendedMoviesFailure = createAction('[Recommendation API] Load movies failure', props<{error: any}>());

const actions = union({
    loadRecommendedMovies,
    loadRecommendedMoviesSuccess,
    loadRecommendedMoviesFailure
});

export type ActionsUnion = typeof actions;

In the reducer, we use a loading flag so the UI can show a loading spinner. The store is updated with arriving data.

recommendation/actions/index.ts

export interface State {
  items: Movie[];
  loading: boolean;
}

export const initialState: State = {
  items: [],
  loading: false
};

export function reducer(state = initialState, action: recommendationActions.ActionsUnion): State {
  switch (action.type) {
    case '[Recommendation List] Load movies':
      return {
        ...state,
        items: [],
        loading: true
      };

    case '[Recommendation API] Load movies failure':
      return {
        ...state,
          loading: false
      };

    case '[Recommendation API] Load movies success':
      return {
        ...state,
        items: action.movies,
        loading: false
      };

    default:
      return state;
  }
}

export const getAll = (state: State) => state.items;
export const isLoading = (state: State) => state.loading;

We need an API service to talk to the server. For demonstration purposes, we simulate an answer delayed by one second:

recommendation/services/recommendation-api.service.ts

@Injectable({
  providedIn: 'root'
})
export class RecommendationApiService {

  private readonly recommendedMovies: Movie[] = [
    {
      id: 2,
      title: 'The Hunger Games',
      genre: 'sci-fi',
      releaseYear: 2012,
      runtimeMinutes: 144
    },
    {
      id: 4,
      title: 'Avengers: Endgame',
      genre: 'fantasy',
      releaseYear: 2019,
      runtimeMinutes: 181
    }
  ];

  loadRecommendedMovies(): Observable<Movie[]> {
    return of(this.recommendedMovies).pipe(delay(1000));
  }
}

Here are the effects:

recommendation/services/recommendation-api.service.ts

@Injectable()
export class RecommendationEffects {

  constructor(
    private actions$: Actions,
    private recommendationApi: RecommendationApiService,
  ) { }

  @Effect()
  loadBooks$ = this.actions$.pipe(
    ofType(recommendationActions.loadRecommendedMovies.type),
    switchMap(() => this.recommendationApi.loadRecommendedMovies().pipe(
      map(movies => recommendationActions.loadRecommendedMoviesSuccess({ movies })),
      catchError(error => of(recommendationActions.loadRecommendedMoviesFailure({ error })))
    ))
  );
}

Effects are always observables and return actions. In this example, we consume the actions observable provided by NgRx and listen only for the loadRecommendedMovies actions by using the ofType operator. Using switchMap, we map to a new observable, one that loads movies and maps the successful result to a new loadRecommendedMoviesSuccess action or a failure to loadRecommendedMoviesFailure. In a real application we would show a notification in the error case.

Note

If an effect should not dispatch another action, return an empty observable.

19.4. Simplifying CRUD with NgRx/Entity

Most of the time when manipulating entries in the store, we like to create, add, update, or delete entries (CRUD). NgRx/Entity provides convenience functions if each item of a collection has an id property. Luckily all our entities already have this property.

Let’s add functionality to add a movie to the watchlist. First, create the required action:

recommendation/actions/index.ts

export const addToWatchlist = createAction('[Recommendation List] Add to watchlist',
    props<{ watchlistItemId: number, movie: Movie, addedAt: Date }>());
Note

You may wonder why the Date object is not created inside the reducer instead, since it should always be the current time. However, remember that reducers should be deterministic state machines — State A + Action B should always result in the same State C. This makes reducers easily testable.

Then, rewrite the watchlistData reducer to make use of NgRx/Entity:

recommendation/actions/index.ts

export interface State extends EntityState<WatchlistItem> { (1)
}

export const entityAdapter = createEntityAdapter<WatchlistItem>(); (2)

export const initialState: State = entityAdapter.getInitialState(); (3)

const entitySelectors = entityAdapter.getSelectors();

export function reducer(state = initialState, action: playbackActions.ActionsUnion | recommendationActions.ActionsUnion): State {
  switch (action.type) {
    case playbackActions.playbackFinished.type:
      const itemToUpdate = entitySelectors
      .selectAll(state) (4)
      .find(item => item.movie.id === action.movieId);
      if (itemToUpdate) {
        return entityAdapter.updateOne({ (5)
          id: itemToUpdate.id,
          changes: { playbackMinutes: action.stoppedAtMinute } (6)
        }, state);
      } else {
        return state;
      }

    case recommendationActions.addToWatchlist.type:
      return entityAdapter.addOne({id: action.watchlistItemId, movie: action.movie, added: action.addedAt, playbackMinutes: 0}, state);

    default:
      return state;
  }
}


export const getAllItems = entitySelectors.selectAll;
  1. NgRx/Entity requires state to extend EntityState. It provides a list of ids and a dictionary of id ⇒ entity entries

  2. The entity adapter provides data manipulation operations and selectors

  3. The state can be initialized with getInitialState(), which accepts an optional object to define any additional state beyond EntityState

  4. selectAll returns an array of all entities

  5. All adapter operations consume the state object as the last argument and produce a new state

  6. Update methods accept a partial change definition; you don’t have to clone the object

This concludes the tutorial on NgRx. If you want to learn about advanced topics such as selectors with arguments, testing, or router state, head over to the official NgRx documentation.

Last updated 2019-12-11 12:58:44 UTC