-
Notifications
You must be signed in to change notification settings - Fork 35
guide ngrx 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.
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 { }
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 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(),
}
]
};
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
);
-
The feature state, each member is managed by a different reducer
-
Feature states are registered by the
forFeature
method. This interface provides a typesafe path from root to feature state. -
Tie substates of a feature state to the corresponding reducers
-
Create a selector to access the 'watchlist' feature state
-
select the watchlistData sub state
-
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>
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.
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 |
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 }));
}
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 |
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.
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.
Continue with learning about effects
This documentation is licensed under the Creative Commons License (Attribution-NoDerivatives 4.0 International).