guide ngrx simple store - devonfw/devon4ng GitHub Wiki

Table of Contents

State, Selection and Reducers

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.

⚠️

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;
}
ℹ️

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 back-end 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 sub-state 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 sub-states 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.

💡

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 facilitate 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 watching 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 (…​).

🔥

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 1. Redux devtools

Continue with learning about effects

⚠️ **GitHub.com Fallback** ⚠️