-
Notifications
You must be signed in to change notification settings - Fork 0
4. NgRx
-
In order to use NgRx in our project, we need to install the required NgRx libraries:
-
store- RxJS powered state management for Angular apps, inspired by Redux. -
store-devtools- Instrumentation for @ngrx/store enabling time-travel debugging (will ad din challenge) -
effects- Side effect model for @ngrx/store. (will add in challenge)
-
Run the following command in the terminal:
ng add @ngrx/store
Normally, in your day to day projects, you would install these libraries using the ng add command, but for the following exercises well go through a more manual approach in order to understand the code that would otherwise be generated for you.
Next, we need to make sure the StoreModule has been added to our app:
- Open
src/app/app.module.ts. First, notice import theStoreModulefrom@ngrx/store:
import { StoreModule } from '@ngrx/store';- Next, notice the new import addition in the
AppModule:
@NgModule({
...
imports: [
...
StoreModule.forRoot({}, {})
],
...
})
export class AppModule {}-
If we take a look at the code:
- The
forRootmethod is a static method of the module and is used to initialize the module (eg register the providers. etc). The first argument is a list of reducers, which will be empty, for now. - This (the root module) is the only place where you need to use this method. For child/feature modules you will need to import the
StoreModulebut you will use theforFeaturestatic method, to configure the specific part of the state tree for each module.
- The
-
We’ve now added NgRx in the main application module, but we still need to add it at the feature module as well. In the
src/app/movies/movies.module.tsfile (after you’ve imported theStoreModulefrom@ngrx/store, same as inAppModule) register theStoreModulein the imports array using the forFeature static function:
import { StoreModule } from '@ngrx/store';
...
@NgModule({
imports: [
...
StoreModule.forFeature('moviesFeature', {})
],
...
})
export class MoviesModule {}-
If we take a look at the code:
- The first argument is the name of the part of the state tree that is registered for this module (in our case, everything will be under
movies). The second argument is the reducers list, which is empty for now but we’ll add some in the next section.
- The first argument is the name of the part of the state tree that is registered for this module (in our case, everything will be under
-
Restart the app by running
ng serveagain in order to recompile and integrate the new modules.
We'll now add the first action object for our project - an action that will load all the movies in our app. An Action in NgRx is made up of a simple interface:
interface Action {
type: string;
}The interface has a single property, the type, represented as a string. The type property is for describing the action that will be dispatched in your application. The value of the type comes in the form of [Source] Event and is used to provide a context of what category of action it is, and where an action was dispatched from. You add properties to an action to provide additional context or metadata for an action. The most common property is the payload, which adds any associated data needed for the action.
- In the
src/app/moviesfolder create a new folder calledstoreand in this folder create a new file calledmovies.actions.ts
Complete file path:
src/app/movies/store/movies.actions.ts
- In the
movies.actions.tsfile create a new action constant:
import { createAction, props } from '@ngrx/store';
export const loadMovies = createAction(
'[Movies] Load movies'
);- We need to add 2 more actions for the successful loading and failure. This time the actions will else receive a
payloadobject in the constructor (make sure to import theMovieinterface from the model folder):
import { Movie } from '../model/movie';
...
export const loadMoviesSuccess = createAction(
'[Movies] Load movies success',
props<{ username: string; password: string }>()
);
export const loadMoviesFail = createAction(
'[Movies] Load movies fail',
props<{ username: string; password: string }>()
);-
If we look at the code:
- The
loadMoviesSuccessaction creator takes a payload that will consist of aMoviearray that will be stored in the state object and used to display the movies. - The
loadMoviesFailaction creator takes a payload with the error received from the server. We can use this to show a propper error message to the user.
- The
-
The consumers of actions, whether it be reducers or effects use the type information from an action to determine whether they need to handle the action. Actions are grouped together by feature area.
Action's only responsibilities are to express unique events and intents. We need a way to handle them. This is where reducers come into play.
Reducers in NgRx are responsible for handling transitions from one state to the next state in your application. Reducer functions handle these transitions by determining which actions to handle based on the type.
- In the
src/app/movies/store/create a new file calledmovies.reducers.tsmovies
Complete file path:
src/app/movies/store/movies.reducers.ts
- In this file, we'll create the store data structure for the movies. For this we will create an interface to define the "shape" of the movies state (don’t forget to import
Moviefrom the model folder):
import { Movie } from '../model/movie';
export interface MovieState {
movies: Movie[];
loading: boolean;
error: any;
}- Next, we'll set up the initial state for our movies, that will be of type
MovieState:
const initialState: MovieState = {
movies: [],
loading: false,
error: undefined,
};- And finally, we'll create the reducer creator:
import { createReducer } from '@ngrx/store';
...
const scoreboardReducer = createReducer(initialState);- Next, we need to create the reducer function. Reducer functions are pure functions in that they produce the same output for a given input. They are without side effects and handle each state transition synchronously. Each reducer function takes the latest
Actiondispatched, the current state, and determines whether to return a newly modified state or the original state. The reducer function will be called each time an action is dispatched, returns a new state (of typeMovieState) and takes 2 arguments:- The current state of the store - this will passed from the store itself and will be used to construct the new state based on the action type
-
The action - this is an object that contains the action type (mandatory if the state is going to be modified in any way, otherwise the same state will be returned) and an optional payload object, in case extra data is needed to obtain the new state (eg. a new movie is added to the collection). In our example, the action type is
MovieActions- the action union type we defined in the previous challenge.
import { createReducer, Action } from '@ngrx/store';
...
export function reducer(state: MovieState | undefined, action: Action) {
return moviesReducer(state, action);
}- The reducer function's responsibility is to handle the state transitions in an immutable way. Next we'll handle the
loadMoviesaction. First, we need to import the action constants from the actions file:
import * as MoviesActions from './movies.actions';Reducers use the action types defined with your actions creators in order to make decisions about which code to execute.
- Next, we need to implement the handling in the reducer function for the action case. We do this by using the
onfunction (imported from@ngrx/store) tat takes the current state and the action payload as parameters and return a new state:
import { createReducer, Action, on } from '@ngrx/store';
...
const moviesReducer = createReducer(
initialState,
on(MoviesActions.loadMovies, (state) => ({
// code to handle the action
// return the new state (immutable)
})),
);- The code of the
onfunction will simply set the loading status totrue, and leave the previous state as it was. For this we’re using the object spread operator to override theloadingproperty on the existing stage object, and return a new object.
on(MoviesActions.loadMovies, (state) => ({
...state,
loading: true,
})),Note: The spread operator only does shallow copying and does not handle deeply nested objects. You need to copy each level in the object to ensure immutability. There are libraries that handle deep copying including lodash and immer.
- Next we handle the successful loading of movies, by placing the data received in the data property of the state object. You might notice that we’re modifying all the properties of the state. Still, it is good practice to leave the spread state, in case we later add new keys to the state. Also, notice that the new movies array will be stored in the
payloadof the action, in themoviesproperty.
on(MoviesActions.loadMoviesSuccess, (state, { movies }) => ({
...state,
loading: false,
error: undefined,
movies,
})),- Finally, we handle the unsuccessful loading of movies, by setting the
errorproperty with the received error property on the action payload:
on(MoviesActions.loadMoviesFail, (state, error) => ({
...state,
loading: false,
error,
}))In the example above, the reducer is handling 3 actions: loadMovies, loadMoviesSuccess, and loadMoviesFail. Each action is strongly-typed and each action handles the state transition immutably. This means that the state transitions are not modifying the original state, but are returning a new state object using the spread operator. The spread syntax copies the properties from the current state into the object, creating a new reference. This ensures that a new state is produced with each change, preserving the purity of the change. This also promotes referential integrity, guaranteeing that the old reference was discarded when a state change occurred.
The state of your application is defined as one large object. Registering reducer functions to manage parts of your state only defines keys with associated values in the object. To register the global Store within your application, used the StoreModule.forRoot() method which registers the global providers for your application, including the Store service you inject into your components and services to dispatch actions and select pieces of state. For feature modules of the application we will use the StoreModule.forFeature() method that will set up the store in the correct way, know that our module is lazy loaded.
- In the
MoviesModulefile after you’ve imported the reducer function, add it to the store module reducer object using theforFeaturestatic method:
import { reducer } from './store/movies.reducers';
...
@NgModule({
imports: [
...
StoreModule.forFeature('moviesFeature', reducer)
]
...
})
export class MoviesModule {}- This is how the state tree currently looks:
{
moviesFeature: {
movies: [],
loading: false,
error: undefined
}
}-
moviesFeatureis the name of the feature and is the part of the state that the reducer you just wrote takes care of. The object contained here is themoviesstate tree where all state related to movies will be held.
Next, we will bring this state object into our movies component.
Selectors are pure functions used for obtaining slices of store state. @ngrx/store provides a few helper functions for optimizing this selection. Selectors provide many features when selecting slices of state.
- In order to make use of selectors, in the
movies.reducers.tsfile we can create a function to extract themoviesarray from the state:
export const getMovies = (state: MovieState): Movie[] => state.movies;- In order to use this function, we first need to create the store selectors to extract the state corresponding to the feature. First, the feature selector that uses the
createFeatureSelectorfunction (imported from@ngrx/store) and selects the first level of the state (themoviesFeatureproperty of the state):
import { createFeatureSelector } from '@ngrx/store';
...
const getMoviesFeatureState = createFeatureSelector<MovieState>('moviesFeature');The createFeatureSelector is a convenience method for returning a top level feature state. It returns a typed selector function for a feature slice of state.
- We’ve selected the movies part of the state, now all we need to do is to create a selector to extract the data, using the previous selector and the state extraction function:
import { createFeatureSelector, createSelector } from '@ngrx/store';
...
export const getAllMovies = createSelector<MovieState, MovieState, Movie[]>(
getMoviesFeatureState,
getMovies,
);The createSelector function takes a selector as a first argument, and state projection functions (or other selectors) as the next parameters. The result of the las one is the return value of the selector.
When using the createSelector and createFeatureSelector functions @ngrx/store keeps track of the latest arguments in which your selector function was invoked. Because selectors are pure functions, the last result can be returned when the arguments match without reinvoking your selector function. This can provide performance benefits, particularly with selectors that perform expensive computation. This practice is known as memoization.
We now have the store all set up, we just need to start interacting with it. The first component that will use the store is the MovieListComponent (in src/app/movies/components/movie-list/). The first thing we need to do is inject the Store service (after we’ve imported it from @ngrx/store). Notice that the Store service accepts a generic type which will tell it how the state is modeled.
- First we need to remove the
MovieServiceservice injection and comment out all the code that uses the service, for now. Next and add theStoreservice in the constructor:
import { Store } from '@ngrx/store';
import { MovieState } from '../../store/movies.reducers';
...
export class MovieListComponent implements OnInit {
constructor(private store: Store<MovieState>) { }
...
}- Next, we need to use the store to get the current state. For this, we use a store selector. Ngrx provides a convenience
selectoperator on theStoreprovider that receives either a string or a selector function and returns an observable that emits the value of the required piece of state. The cool part is that this observable will emit every time the state has changed so we can can act accordingly. For now, we select the wholemoviesFeaturesection of the state, we subscribe to the observable and we get the movie data (ignore any type errors you get, for now):
...
import { Store, select } from '@ngrx/store';
import { MovieState, getAllMovies } from '../../store/movies.reducers';
...
export class MovieListComponent implements OnInit {
...
ngOnInit() {
...
this.store.pipe(select(getAllMovies)).subscribe(state => {
console.log(state);
});
...
}
...
}Notice how we replaced all concern regarding the extraction of the state data. The component does not need to know anything about how the state is structured. If at some point we need to refactor the state tree, we only need to modify the selector. All other places that get this data through the selector can remain unchanged.
We're also making use of the async pipe which automatically manages the subscription of the observable so we don't have to keep track of it.
- For this moment this selector isn’t doing something very spectacular. It’s time to add some data. From the
/db/db.jsonfile, copy the first movie object:
{
id: '1',
title: 'Star Wars: The Last Jedi',
year: 2017,
genre: 'Action, Adventure, Fantasy',
plot: 'Rey develops her newly discovered abilities with the guidance of Luke Skywalker, who is unsettled by the strength of her powers. Meanwhile, the Resistance prepares to do battle with the First Order.',
poster: 'https://images-na.ssl-images-amazon.com/images/M/MV5BMjQ1MzcxNjg4N15BMl5BanBnXkFtZTgwNzgwMjY4MzI@._V1_SX300.jpg',
comment: ''
}- Now paste it in the
src/app/movies/store/movies.reducers.tsfile, in themoviesarray of theinitialState:
const initialState: MovieState = {
movies: [
{
id: 1,
title: 'Star Wars: The Last Jedi',
...
}
],
loading: false,
error: undefined,
};- Next, in the
movies-list.component.tsfile we need to extract the movies, and display them on the screen. Since theselectmethod returns an observable we can reuse the$movies$property on the class and asign it the value received from the state selector:
export class MovieListComponent implements OnInit {
movies$: Observable<Movie[]>;
ngOnInit() {
this.movies$ = this.store.select<MovieState>('moviesFeature');
...
}
}If you reload the page, you should see the first movie in the list.
Now that we list movies, we continue by adding a new action to create a new movie.
- Start by adding a new action creator in
movies.action.ts:
export const addMovie = createAction(
'[Movie] Add movie',
props<Movie>()
);Next we're going to handle the action in the reducer in movies.reducers.ts.
- Add an
onfunction to handle the action, then add a newMoviefrom the action payload to the movies array :
const moviesReducer = createReducer(
...
on(MoviesActions.addMovie, (state, movie) => ({
...state,
movies: [ ...state.movies, movie ]
}))
);Note: remember that all operations need to be immutable; for this reason we're re-creating the movies array and add in the new one at the end.
The final phase is to "invoke" the action from our code. this is done using the dispatch method found on the store service. This method receives a new instance of an action that is then sent ot the reducer for handling.
- In the
movie-detail-reactive.component.ts(insrc/app/movies/components/movie-detail-reactive/) remove theMovieServiceinjection .
IMPORTANT: For the moment, comment the code in onSubmit, onDestroy and ngOnInit (exept for the form building part).
- Inject the
Storeprovider from@ngrx/store:
import { Store } from '@ngrx/store';
import { MovieState } from '../../store/movies.reducers';
...
export class MovieDetailReactiveComponent implements OnInit {
...
constructor(
...
private store: Store<MovieState> // inject the store provider
) { }
...
}- Now, when we add a new movie, we need to dispatch the newly created action (don't forget to import
AddMovie) to the store. we do this using thedispatchmethod on the store provider:
...
import { AddMovie } from '../../store/movies.actions';
...
export class MovieDetailComponent implements OnInit, OnDestroy {
...
onSubmit() {
const { value } = this.movieForm;
const modifiedMovie = {
...this.movie,
...value,
};
if (!this.movieId) {
this.store.dispatch(addMovie(modifiedMovie));
this.goBack();
} else {
this.movieService.updateMovie(modifiedMovie).subscribe(this.goBack);
}
}
...
}Add a movie and see it in the result list.
In order to visualize the state and all the actions that go on the app, we can use a browser plugin called Redux dev-tools.
First, we need to install the module. Run the following command:
ng add @ngrx/store-devtools
- In the
app.module.tsfile add the following module was added:
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
...
@NgModule({
...
imports: [
...
StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }),
],
...
})
export class AppModule {}- Download the chrome extension from here. Restart the browser and fire up the dev tools. When you add a movie you should see the action in the devtools.
For the moment we only have static data in our store but we need to get the list of movies from the server. In order to do this we need to make a server call from the http service, which is outside of the sore system. Using effects we can integrate operations that happen outside of the store (side effects) by executing these side effects and dispatching the required actions to properly update the store. Effects are where you handle tasks such as fetching data, long-running tasks that produce multiple events, and other external interactions where your components don't need explicit knowledge of these interactions. This way, anyone that’s listening to store changes will be notified of the side effects. Effects when used along with Store, decrease the responsibility of the component. In a larger application, this becomes more important because you have multiple sources of data, with multiple services required to fetch those pieces of data, and services potentially relying on other services.
To isolate side-effects from your component, you must create an Effects class to listen for events and perform tasks.
Effects are injectable service classes with distinct parts:
- An injectable Actions service that provides an observable stream of all actions dispatched after the latest state has been reduced.
- Observable streams are decorated with metadata using the Effect decorator. The metadata is used to register the streams that are subscribed to the store. Any action returned from the effect stream is then dispatched back to the Store.
- Actions are filtered using a pipe-able ofType operator. The ofType operator takes one more action types as arguments to filter on which actions to act upon.
- Effects are subscribed to the Store observable.
- Services are injected into effects to interact with external APIs and handle streams.
Let's start implementing the effect responsible for loading the movies list from the server.
- Before we start, we need to ad dthe
Effectsmodule to our app. run the following command:
ng add @ngrx/effects
- Lets start by creating the service. In the
/src/app/movies/storefolder, create a new file calledmovies.effects.ts. In the file, we need to create an injectable provider,calledMoviesEffects:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MoviesEffects {}- In the class constructor, we need to inject 2 providers:
- The
MoviesServiceprovider -> we use this to make calls to the server and offers us basic CRUD movies operations. - The
Actionsprovider -> an observable that fires each time an action is dispatched to the store. We use this “message bus” as a way to filter for specific actions and react to them.
- The
import { Injectable } from '@angular/core';
import { Actions } from '@ngrx/effects';
import { MovieService } from '../services/movie.service';
@Injectable({
providedIn: 'root'
})
export class MoviesEffects {
constructor(private moviesService: MovieService, private actions$: Actions) {}
}- Next, in the
MoviesEffectsclass we add a first effect, in the form of a property calledloadMovies$. This effect is created with an effect creator using thecreateEffectfunction (imported from@ngrx/effetcs). We assign it to theactions$provider so we can monitor for action changes:
...
import { Actions, createEffect } from '@ngrx/effects';
export class MoviesEffects {
...
loadMovies$ = createEffect(() => this.actions$);
}- The first thing the effect needs to do is to intercept the
LOAD_MOVIESaction, and we first need to import theMovieActionsenum:
import * as MovieActions from './movies.actions';- we can now intercept it by using the
ofTypeoperator (imported from@ngrx/effetcs) on theactions$observable:
...
import { Actions, createEffect, ofType } from '@ngrx/effects';
export class MoviesEffects {
...
loadMovies$ = createEffect(() =>
this.actions$.pipe(ofType(MovieActions.loadMovies))
);
}- We need to add the
getMoviesfunction on theMoviesServiceto return an Observable of the http call (we can also remove theloadAllmethod, since we'll be loading the movies through the store):
export class MovieService {
...
getMoviesList(): Observable<Movie[]> {
return this.http.get<Movie[]>(this.apiUrl);
}
...
}- Now, once the action has been dispatched, we need to make the proper service call to get the movies, by calling the
getMoviesListmethod on themoviesService(which returns an observable). We can the use themergeMapoperator to change the emitting observable to the service call:
...
import { mergeMap } from 'rxjs/operators';
export class MoviesEffects {
...
loadMovies$ = createEffect(() =>
.pipe(
ofType(MovieActions.LOAD_MOVIES),
mergeMap(() => this.moviesService.getMovies());
);
}But although the service call is made, the result is still not integrated into the store flow. In order for this to happen we need to dispatch actions to the store.
- We map the result of the server call observable and dispatch a
loadMoviesSuccessaction passing in the movies from the server or aloadMoviesFailaction in case something goes wrong (actions are imported from the./movies.actionsfolder).
...
import { MovieActions, LoadMoviesSuccess, LoadMoviesFail } from './movies.actions';
import { switchMap, map, catchError } from 'rxjs/operators';
...
export class MoviesEffects {
...
loadMovies$ = createEffect(() =>
this.actions$.pipe(
ofType(MovieActions.loadMovies),
mergeMap(() =>
this.moviesService.getMoviesList().pipe(
map((movies) => MovieActions.loadMoviesSuccess({ movies })),
catchError((error) => MovieActions.loadMoviesFail(error))
)
)
)
);
}We’re done with the Effects class, but in order for them to work, we need to register them in the EffectsModule. This is done in 2 steps:
In the /src/app/app.module.ts add the EffectsModule and call the static forRoot method with an empty array - we do not have effects at the root level. We call this method only once and only in the root module:
import { EffectsModule } from '@ngrx/effects';
@NgModule({
...
imports: [
...
EffectsModule.forRoot([]),
],
...
})
export class AppModule {}- In the
/src/app/movies/movies.module.tsadd theEffectsModuleand call the staticforFeaturemethod with an array containing theMoviesEffects. Now the effects are registered and listening for actions:
import { EffectsModule } from '@ngrx/effects';
import { MoviesEffects } from './store/movies.effects';
@NgModule({
...
imports: [
...
EffectsModule.forFeature([MoviesEffects]),
],
...
})
export class MoviesModule {}- The final step is to dispatch the
LoadMoviesaction at the initialization of theMoviesModule, since we only want this to run once. In themovies.module.tsfile , add adispatchcall in the module constructor like this:
import { StoreModule, Store } from '@ngrx/store';
import { reducer, MovieState } from './store/movies.reducers';
import { LoadMovies } from './store/movies.actions';
...
export class MoviesModule {
constructor(private store: Store<MovieState>) {
this.store.dispatch(loadMovies());
}
}- Next, in the
movies-list.component.tstheselectwill emit a new value wen the effect finished executing, the succes action is dispatched and the state is modified again (initially the selector will return[]- as defined in the initialState variable, in the reducers file). We can re-set the initial value for theinitialStatein the reducers file (remove the staticMovie):
const initialState: MovieState = {
movies: [],
loading: false,
error: undefined,
};We need an easier way to work with collections, especially if we're going to select and modify specific items. Entity provides an API to manipulate and query entity collections and offers great help with:
- Reduces boilerplate for creating reducers that manage a collection of models.
- Provides performant CRUD operations for managing entity collections.
- Extensible type-safe adapters for selecting entity information.
In order to use the Entity library, we must first install the module:
ng add @ngrx/entity
- Next we need to define an
EntityAdapterwhich provides a generic type interface for the provided entity adapter. The entity adapter provides many collection methods for managing the entity state, as we'll se later. Add this to themovies.reducers.tsfile:
...
import { EntityAdapter, createEntityAdapter } from '@ngrx/entity';
...
const adapter: EntityAdapter<Movie> = createEntityAdapter<Movie>();The createEntityAdapter is a method for returning a generic entity adapter for a single entity state collection. The returned adapter provides many adapter methods for performing operations against the collection type.
Now we need to update the state definition to hold an entity object rather than a movies collection in the movies property
- First we set an initial state for the entities. The
getInitialStatemethod returns the initialState for entity state based on the provided type. Additional state is also provided through the provided configuration object. TheinitialEntityStateis provided to your reducer function.
const initialEntityState = adapter.getInitialState();- Next we updated the initial state interface and object to contain an initialized
MovieEntityStateobject (don't forget to also importEntityStatefrom@ngrx/entity):
...
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
...
export interface MovieState {
movies: EntityState<Movie>;
loading: boolean;
error: any;
}
const initialState: MovieState = {
movies: initialEntityState,
loading: false,
error: undefined,
};We can now use our newly created adaptor to update the collection once it's loaded.
- Update the
loadMoviesSuccessfunction, and call thesetAllmethod to save the received movie collection in entity format:
on(MoviesActions.loadMoviesSuccess, (state, { movies }) => ({
...state,
loading: false,
error: undefined,
movies: adapter.setAll(movies, state.movies),
})),We now need to update our movies selectors to accomodate the changes.
- Use the
getSelectorsmethod on theadapterto get theselectAllselector that return the movie collection:
const getMovies = (state: MovieState): Movie[] => adapter.getSelectors().selectAll(state.movies);- Finnaly we need to update the
addMoviefunction and call theaddOnemethod on theadapterto easily add a new element to the collection:
on(MoviesActions.addMovie, (state, movie) => ({
...state,
movies: adapter.addOne(movie, state.movies),
}))If you test the app now, it should run like before, but code complexity is way lower for doing entity processing. Let's implement the edit movie functionality through the store.
We start by adding the update actions. We will need to actions:
-
updateMovie- this action will be triggered from the component with a newMovieobject that will be send to the server for persistency using an effect. -
updateMovieSuccess- this action will be triggered after the successful update action and will trigger the update the movies collection in the store.
-
In the
movies.actions.tsfile, add the actions. -
We create the
updateMovieaction that will take aMovieobject as a payload:
export class UpdateMovie implements Action {
readonly type = MovieActions.UPDATE_MOVIE;
constructor(public payload: Movie ) {}
}Next, we create the updateMovieSuccess action that will take an object of type Update<Movie> object as a payload. The Update<T> interface has 2 properties:
-
id- the id of the edited entity -
changes: Partial<T>- a partial (subset of properties) of the edited object type
- We add the action and import the
Updateinterface form@ngrx/entity
...
import { Update } from '@ngrx/entity';
...
export const updateMovieSuccess = createAction(
'[Movie] Update Movie Success',
props<Update<Movie>>()
);Next, for the edit functionality to work, we need to have a way to select a movie with a certain id from the entity collection.
We first create a function that will extract the entities from the MovieState. For this we use the selectEntities method from the adapter.getSelectors() object that will return a Dictionary<Movie> type.
- In the
movies.reducer.tsfile, add thegetMovieEntitiesfunction (don't forget to importDictionaryfrom@ngrx/entity) to extract entities from the state object:
import {
EntityState,
EntityAdapter,
createEntityAdapter,
Dictionary
} from '@ngrx/entity';
export const getMovieEntities = (state: MovieState): Dictionary<Movie> =>
adapter.getSelectors().selectEntities(state.movies);- Next we use the
getMovieEntitiesto define the store selector that will extract the movie entities:
export const getAllEntities = createSelector<MovieState, MovieState, Dictionary<Movie>>(
getMoviesFeatureState,
getMovieEntities,
);- Finally, using the
getAllEntitiesselector, we define a new selector that we use to get a movie with a certain id.
export const getMovieById = createSelector(
getAllEntities,
(movies: Dictionary<Movie>, props: { movieId: string }) => movies[props.movieId]
);Remember that the entities data structure has the following shape which alows us to get an object from the collection using just the id (without doing a lookup like in an array):
{
[entity_id]: [entity],
[entity_id]: [entity],
...
}Also notice that this selector takes a parameter, and when we can pass it in when we use it as follows:
select(getMovieById, {movieId: this.movieId})Now lets take care of the component.
In the movie-detail.component.ts update the properties definitions and values:
export class MovieDetailReactiveComponent implements OnInit, OnDestroy {
public movieForm: FormGroup;
private paramsSub: Subscription;
private movieId: number;
private create;
...
}We'll use the newly created getMovieById selector if we're in edit mode. If not we'll return an empty movies object:
ngOnInit(): void {
this.route.paramMap.pipe(
map((paramsMap): string => paramsMap.get('id')),
tap((movieId) => (this.movieId = movieId)),
switchMap((movieId) =>
this.store.pipe(select(getMovieById, { movieId })) // replace service call with this
.pipe(tap((movie) => (this.movie = movie)))
)
).subscribe(movie => {
this.movieForm.patchValue(movie);
});
}- And finally, when we submit the form, we emit the action:
onSubmit() {
const movieModel = {
...this.movieForm.value,
id: this.movieId,
comment: ''
};
if (this.create) {
this.store.dispatch(new AddMovie(movieModel));
} else {
this.store.dispatch(new UpdateMovie(movieModel));
}
this.goBack();
}- In the
movies.reducer.tswe need to create anoncall to handle theupdateMovieSuccessaction:
case MovieActions.UPDATE_MOVIE_SUCCESS: {
return {
...state,
movies: adapter.updateOne(action.payload, state.movies)
};
}The final step is to add the effect that will update the collection both locally and on the server.
- In the
movies.effects.tsadd theupdateMovie$effect
export class MoviesEffects {
...
updateMovie$ = createEffect(() =>
this.actions$.pipe(
ofType(MovieActions.updateMovie),
mergeMap((movie) =>
this.moviesService.updateMovie(movie).pipe(
map((res: any) => MovieActions.updateMovieSuccess({id: res.id, changes: res}))
)
)
));
}TODO: Update the ADD_MOVIE action to also make the server call:
- Add an
AddMovieSuccessaction - Handle this action in the reducer instead of
AddMovie - Add an
addMovieeffect that:- listenes to the
AddMovieaction - uses
mergeMapto call thecreateMoviein theMovieService(you need to refactor the method as well) - maps the reply to the
AddMovieSuccessaction, passing the call result as payload
- listenes to the
-
Create 2 actions:
-
updateComment- takes thecommentandmovieIdas arguments -
updateCommentSuccess- takes themovie: Update<Movie>as argument
-
-
use the
updateCommentSuccessaction in a newonclause in the reducer, updating the movie entity -
create an
updateComment$effect that:- reacts on the
updateCommentaction - uses the
updateCommenton theMoviesService(must refactor to apatchcall with the movie id and the comment) - and emmits a
updateCommentSuccessaction with the new movie when done when done
- reacts on the
-
update the
handleCommentUpdatemethod inMovieListComponentto dispatch theupdateCommentaction
-
Create 2 actions:
-
deleteMovie- takes themovieIdas argument -
deleteMovieSuccess- takes themovieIdas argument
-
-
use the
deleteMovieSuccessaction in a newonclause in the reducer, deleting the movie entity (use theremoveOnemethod on theadapter) -
create an
deleteMovie$effect that:- reacts on the
deleteMovieaction - uses the
deleteMovieon theMoviesService(must refactor the method to just return adeletecall) - and emmits a
deleteMovieSuccessaction when done
- reacts on the
-
add the
handleDeleteUpdatemethod inMovieListComponentto dispatch thedeleteMovieaction