Manage state with @ngrx/store

Prerequisites

If you don't have installed the Angular CLI do it now:

npm i -g @angular/cli

If and only if you don't have installed the CLI and the last command didn't work (e. g. b/c of your firewall) you can use the project-local CLI installation here. In this case, you have to execute the CLI with npm run. The next snipped shows this by requesting the CLI's version:

npm run ng -- -v

Please note, that you need those two dashes to tell npm that the parameters are not indented for npm but ng.

If you have a newer version of npm, you could also use npx:

npx ng -v

Setup the store

  1. Open your package.json and find out, that some libraries from the @ngrx/* scope have been installed. One of them is @ngrx/schematics which extends the CLI by additional commands we are using in the next steps to generate boilerplate code.

  2. To setup the StoreModule and all the needed imports, switch into the folder flight-app\src\app and run the following command.

    ng generate @ngrx/schematics:store AppState --root --statePath=+state --module=app.module.ts --project=flight-app

  3. Open the new +state folder and its index.ts file.

  4. Open the app.module.ts file and inspect the current changes. You should find some additional imported modules.

    Check whether all import statements in this file work. If not, correct them (sometimes the generated code has some minor issues).

  5. Also import the EffectsModule into the AppModule. Even though we will use it only later, we have to import it now to make the generated code run.

    import { EffectsModule } from '@ngrx/effects';
    
    [...]
    
    imports: [
        [...],
        EffectsModule.forRoot([])
    ];
    

Setup State Management for a Feature Module

  1. To setup the StoreModule for a feature module, switch into the folder flight-app\src\app and use the following command:

    ng generate @ngrx/schematics:feature flight-booking/+state/flight-booking --module=flight-booking/flight-booking.module.ts --creators --api

    Open the new +state folder and inspect the files.

    Inspect all of them and take a look at the flight-booking.module.ts where everything is imported. See that the .forFeature method is called here.

  2. Open the file flight-booking.effects.ts and remove the body of the class FlightBookingEffects as well as all unnecessary imports. Will will come back to this file in a later exercise.

    import {Injectable} from '@angular/core';
    // No other imports, for now
    
    @Injectable()
    export class FlightBookingEffects {
      // No body, for now
    }
    
  3. Open the file flights-booking.reducer.ts. Extend the interface State by a property flights with the type Flight[].

    Show code

    export interface State {
      flights: Flight[]
    }
    

  4. Below, define an empty array as the initial state for the new property initialState.

    Show code

    
    export const initialState: State = {
      flights: []
    };
    
    

  5. In the same file, insert an interface FlightBookingAppState that represents the root nodes view to our State:

    export interface FlightBookingAppState {
      flightBooking: State
    }
    
  6. Open the file flight-booking.actions.ts and setup a flightsLoaded action creator.

    Show code

    You can replace the whole file with the following content:

    [...]
    
    export const flightsLoaded = createAction(
      '[FlightBooking] FlightsLoaded',
      props<{flights: Flight[]}>()
    );
    

  7. Open the file flight-booking.reducer.ts and extend the reducer function so that it handles the flightsLoaded action.

    Show code

    export const flightBookingReducer = createReducer(
      initialState,
    
      on(flightsLoaded, (state, action) => {
        const flights = action.flights;
        return { ...state, flights };
      })
    )
    

  8. Open the file flight-search.component.ts. Inject the Store into the constructor. Introduce a property flights$ (Observable<Flight[]>) which points to the flights in the store.

    Show code

    export class FlightSearchComponent implements OnInit {
    
      [...]
    
      flights$ = this.store.select(s => s.flightBooking.flights);
    
      constructor(
        [...]
        private store: Store<FlightBookingAppState>
      ) { }
    
      [...]
    }
    

  9. Modify the component's search method so that the loaded flights are send to the store. For this, use the FlightService's find method instead of the load method and dispatch a flightsLoaded action.

    Show code

    search(): void {
      if (!this.from || !this.to) return;
    
      // old:
      // this.flightService.load(...)
    
      // new:
      this.flightService
          .find(this.from, this.to, this.urgent)
          .subscribe({
            next: flights => { 
              this.store.dispatch(flightsLoaded({flights}));
            },
            error: error => {
              console.error('error', error);
            } 
          });
    }
    

  10. Open the component's template, flight-search.component.html, and use the observable flights$ together with the async pipe instead of the array flights.

    Show code

    <div *ngFor="let f of flights$ | async">
      <div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
        <flight-card [...]></flight-card>
      </div>
    </div>
    

  11. Test your solution

  12. If not already installed, install the Chrome plugin Redux DevTools and use it to trace the dispatched actions.

    To install it, use Chrome to navigate to this page.

Update a Flight

In this exercise you will write an action for updating a flight. You will use it to delay the first flight by 15 minutes.

This exercise shows that working with immutables in JavaScript is not always as straight we would like it to be.

  1. Open the file flight-booking.actions.ts and add a updateFlight action creator for updating a changed flight in the store.

    Show code

    [...]
    
    export const updateFlight = createAction(
      '[FlightBooking] Update Flight',
      props<{flight: Flight}>()
    );
    

  2. Open the file flight-booking.reducer.ts and update the reducer to handle the FlightUpdateAction.

    Show code

    [...]
    const flightBookingReducer = createReducer(
      initialState,
    
      on(flightsLoaded, (state, action) => {
        const flights = action.flights;
        return { ...state, flights };
      }),
    
      // New:
      on(updateFlight, (state, action) => {
        const flight = action.flight;
        const flights = state.flights.map(f => f.id === flight.id? flight: f);
        return { ...state, flights };
      })
    );
    [...]
    

  3. Open the flight-search.component.ts. Within the delay method, dispatch a FlightUpdateAction that delays the first found flight for 15 minutes. As the flight is immutable, you have to create a new one.

    Show code

    delay(): void {
      
      this.flights$.pipe(take(1)).subscribe(flights => {
        const flight = flights[0];
    
        const oldDate = new Date(flight.date);
        const newDate = new Date(oldDate.getTime() + 15 * 60 * 1000);
        const newFlight = { ...flight, date: newDate.toISOString() };
        
        this.store.dispatch(updateFlight({flight: newFlight}));
      });
    }
    

  4. Open the flight-search.component.html file and make sure that the the Delay button uses the flights$ observable instead of the flights array. A very simple way to accomplish this is using the async pipe:

    <ng-container *ngIf="flights$ | async as flights">
      <button *ngIf="flights.length > 0" class="btn btn-default"
        (click)="delay()">
        Delay 1st Flight
      </button>
    </ng-container>
    

    If there is time, your instructor will discuss alternatives for this with you.

  5. Test your solution.

Bonus: Connecting the Router to the Store **

The StoreRouterConnectingModule helps to sync your router with the store. This exercise shows how to use it.

  1. Open your file flight-app/src/app/+state/index.ts, import the routerReducer from @ngrx/router-store and register it:

    import { routerReducer } from '@ngrx/router-store';
    [...]
    
    export const reducers: ActionReducerMap<State> = {
      router: routerReducer
    };
    
  2. In your app.module, configure the StoreRouterConnectingModule as shown below:

    import { RouterState } from '@ngrx/router-store';
    [...]
    
    @NgModule({
      imports: [
        [...]
        StoreRouterConnectingModule.forRoot({stateKey: 'router', routerState: RouterState.Minimal })
      ],
      [...]
    })
    export class AppModule {
    }
    
  3. Start your application and navigate between the menu points. Open the Redux Devtools and replay all actions. Your should see, that the visited routes are replayed too.

Bonus: Using Mutables with ngrx-immer *

The project ngrx-immer uses the library immer to allow mutating the state. At least, it looks like this. However, your mutations are only recorded and replayed in an immutable way. As a result, you can write your code as you are used to and although you get the benefits of immutables.

You can quickly try this out:

  1. Install ngrx-immer: npm i ngrx-immer immer
  2. Lookup its readme: https://www.npmjs.com/package/ngrx-immer
  3. Update your reducer so that it uses immerOn instead of on as shown in the readme file

Hint: If the auto-import feature of your IDE does not work, you can add this import by hand:

import { immerOn } from 'ngrx-immer/store';