Skip to content

4. NgRx

Andrei Antal edited this page Sep 25, 2021 · 1 revision

Challenge 18 - Add NgRx to the project

  • 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 the StoreModule from @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 forRoot method 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 StoreModule but you will use the forFeature static method, to configure the specific part of the state tree for each module.
  • 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.ts file (after you’ve imported the StoreModule from @ngrx/store, same as in AppModule) register the StoreModule in 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.
  • Restart the app by running ng serve again in order to recompile and integrate the new modules.

Diffs

Diffs

Challenge 19 - Add your first actions

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/movies folder create a new folder called store and in this folder create a new file called movies.actions.ts

Complete file path:
src/app/movies/store/movies.actions.ts

  • In the movies.actions.ts file 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 payload object in the constructor (make sure to import the Movie interface 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 loadMoviesSuccess action creator takes a payload that will consist of a Movie array that will be stored in the state object and used to display the movies.
    • The loadMoviesFail action 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 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.

Diffs

Challenge 20 - Add your first reducer

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 called movies.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 Movie from 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 Action dispatched, 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 type MovieState) 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 loadMovies action. 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 on function (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 on function will simply set the loading status to true, and leave the previous state as it was. For this we’re using the object spread operator to override the loading property 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 payload of the action, in the movies property.
on(MoviesActions.loadMoviesSuccess, (state, { movies }) => ({
  ...state,
  loading: false,
  error: undefined,
  movies,
})),
  • Finally, we handle the unsuccessful loading of movies, by setting the error property 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 MoviesModule file after you’ve imported the reducer function, add it to the store module reducer object using the forFeature static 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
  }
}
  • moviesFeature is 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 the movies state tree where all state related to movies will be held.

Next, we will bring this state object into our movies component.

Diffs

Challenge 21 - State selectors

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.ts file we can create a function to extract the movies array 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 createFeatureSelector function (imported from @ngrx/store) and selects the first level of the state (the moviesFeature property 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.

Diffs

Challenge 22 - The store

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 MovieService service injection and comment out all the code that uses the service, for now. Next and add the Store service 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 select operator on the Store provider 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 whole moviesFeature section 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.json file, 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.ts file, in the movies array of the initialState:
const initialState: MovieState = {
  movies: [
    {
      id: 1,
      title: 'Star Wars: The Last Jedi',
      ...
    }
  ],
  loading: false,
  error: undefined,
};
  • Next, in the movies-list.component.ts file we need to extract the movies, and display them on the screen. Since the select method 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.

Diffs

Challenge 23 - Adding a movie

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 on function to handle the action, then add a new Movie from 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 (in src/app/movies/components/movie-detail-reactive/) remove the MovieService injection .

IMPORTANT: For the moment, comment the code in onSubmit, onDestroy and ngOnInit (exept for the form building part).

  • Inject the Store provider 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 the dispatch method 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.

Diffs

Challenge 24 - Redux devtools

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.ts file 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.

Diffs

Challenge 25 - Effects

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 Effects module to our app. run the following command:
ng add @ngrx/effects
  • Lets start by creating the service. In the /src/app/movies/store folder, create a new file called movies.effects.ts. In the file, we need to create an injectable provider,called MoviesEffects:
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class MoviesEffects {}
  • In the class constructor, we need to inject 2 providers:
    • The MoviesService provider -> we use this to make calls to the server and offers us basic CRUD movies operations.
    • The Actions provider -> 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.
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 MoviesEffects class we add a first effect, in the form of a property called loadMovies$. This effect is created with an effect creator using the createEffect function (imported from @ngrx/effetcs). We assign it to the actions$ 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_MOVIES action, and we first need to import the MovieActions enum:
import * as MovieActions from './movies.actions';
  • we can now intercept it by using the ofType operator (imported from @ngrx/effetcs) on the actions$ observable:
...
import { Actions, createEffect, ofType } from '@ngrx/effects';

export class MoviesEffects {
  ...
  loadMovies$ = createEffect(() =>
    this.actions$.pipe(ofType(MovieActions.loadMovies))
  );
}
  • We need to add the getMovies function on the MoviesService to return an Observable of the http call (we can also remove the loadAll method, 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 getMoviesList method on the moviesService (which returns an observable). We can the use the mergeMap operator 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 loadMoviesSuccess action passing in the movies from the server or a loadMoviesFail action in case something goes wrong (actions are imported from the ./movies.actions folder).
...
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.ts add the EffectsModule and call the static forFeature method with an array containing the MoviesEffects. 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 LoadMovies action at the initialization of the MoviesModule, since we only want this to run once. In the movies.module.ts file , add a dispatch call 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.ts the select will 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 the initialState in the reducers file (remove the static Movie):
const initialState: MovieState = {
  movies: [],
  loading: false,
  error: undefined,
};

Diffs

Challenge 26 - Entity

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 EntityAdapter which 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 the movies.reducers.ts file:
...
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 getInitialState method returns the initialState for entity state based on the provided type. Additional state is also provided through the provided configuration object. The initialEntityState is provided to your reducer function.
const initialEntityState = adapter.getInitialState();
  • Next we updated the initial state interface and object to contain an initialized MovieEntityState object (don't forget to also import EntityState from @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 loadMoviesSuccess function, and call the setAll method 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 getSelectors method on the adapter to get the selectAll selector that return the movie collection:
const getMovies = (state: MovieState): Movie[] => adapter.getSelectors().selectAll(state.movies);
  • Finnaly we need to update the addMovie function and call the addOne method on the adapter to 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 new Movie object 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.ts file, add the actions.

  • We create the updateMovie action that will take a Movie object 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 Update interface 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.ts file, add the getMovieEntities function (don't forget to import Dictionary from @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 getMovieEntities to define the store selector that will extract the movie entities:
export const getAllEntities = createSelector<MovieState, MovieState, Dictionary<Movie>>(
  getMoviesFeatureState,
  getMovieEntities,
);
  • Finally, using the getAllEntities selector, 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.ts we need to create an on call to handle the updateMovieSuccess action:
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.ts add the updateMovie$ 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 AddMovieSuccess action
  • Handle this action in the reducer instead of AddMovie
  • Add an addMovie effect that:
    • listenes to the AddMovie action
    • uses mergeMap to call the createMovie in the MovieService (you need to refactor the method as well)
    • maps the reply to the AddMovieSuccess action, passing the call result as payload

Diffs

Challenge 27 - Delete and edit comment

1. Edit comment

  • Create 2 actions:

    • updateComment - takes the comment and movieId as arguments
    • updateCommentSuccess - takes the movie: Update<Movie> as argument
  • use the updateCommentSuccess action in a new on clause in the reducer, updating the movie entity

  • create an updateComment$ effect that:

    • reacts on the updateComment action
    • uses the updateComment on the MoviesService (must refactor to a patch call with the movie id and the comment)
    • and emmits a updateCommentSuccess action with the new movie when done when done
  • update the handleCommentUpdate method in MovieListComponent to dispatch the updateComment action

2. Delete movie

  • Create 2 actions:

    • deleteMovie - takes the movieId as argument
    • deleteMovieSuccess - takes the movieId as argument
  • use the deleteMovieSuccess action in a new on clause in the reducer, deleting the movie entity (use the removeOne method on the adapter)

  • create an deleteMovie$ effect that:

    • reacts on the deleteMovie action
    • uses the deleteMovie on the MoviesService (must refactor the method to just return a delete call)
    • and emmits a deleteMovieSuccess action when done
  • add the handleDeleteUpdate method in MovieListComponent to dispatch the deleteMovie action