Manage State with NGRX
Setup the store
-
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. -
To setup the
StoreModule
and all the needed imports, switch into the folderflight-app\src\app
and run the following command.npx ng generate @ngrx/schematics:store AppState --root --statePath=+state --module=app.module.ts --project=flight-app
-
Open the new
+state
folder and itsindex.ts
file. -
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). -
Also import the
EffectsModule
into theAppModule
. 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
-
To setup the
StoreModule
for a feature module, switch into the folderflight-app\src\app
and use the following command:npx ng generate @ngrx/schematics:feature flight-booking/+state/flight-booking --module=flight-booking/flight-booking.module.ts --creators
If you are asked, whether to wire up success and failure functions, answer with "no". We'll do this by hand in this workshop.
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. -
Open the file
flight-booking.effects.ts
and remove the body of the classFlightBookingEffects
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 }
-
Open the file
flights-booking.reducer.ts
. Extend the interfaceState
by a propertyflights
with the typeFlight[]
.Show code
export interface State { flights: Flight[]; }
-
Below, define an empty array as the initial state for the new property
initialState
.Show code
export const initialState: State = { flights: [], };
-
In the same file, insert an interface
FlightBookingAppState
that represents the root nodes view to our State:export interface FlightBookingAppState { flightBooking: State; }
-
Open the file
flight-booking.actions.ts
and setup aflightsLoaded
action creator.Show code
You can replace the whole file with the following content:
[...] export const flightsLoaded = createAction( '[FlightBooking] FlightsLoaded', props<{flights: Flight[]}>() );
-
Open the file
flight-booking.reducer.ts
and extend the reducer function so that it handles theflightsLoaded
action.Show code
export const reducer = createReducer( initialState, on(flightsLoaded, (state, action) => { const flights = action.flights; return { ...state, flights }; }) )
-
Open the file
flight-search.component.ts
. Inject the Store into the constructor. Introduce a propertyflights$
(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> ) {} [...] }
-
Modify the component's
search
method so that the loaded flights are send to the store. For this, use theFlightService
'sfind
method instead of theload
method and dispatch aflightsLoaded
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); } }); }
-
Open the component's template,
flight-search.component.html
, and use the observableflights$
together with theasync
pipe instead of the arrayflights
.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>
-
Test your solution
-
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.
-
Open the file
flight-booking.actions.ts
and add aupdateFlight
action creator for updating a changed flight in the store.Show code
[...] export const updateFlight = createAction( '[FlightBooking] Update Flight', props<{flight: Flight}>() );
-
Open the file
flight-booking.reducer.ts
and update the reducer to handle theFlightUpdateAction
.Show code
[...] export const reducer = 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 }; }) ); [...]
-
Open the
flight-search.component.ts
. Within thedelay
method, dispatch aFlightUpdateAction
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})); }); }
-
Open the
flight-search.component.html
file and make sure that the theDelay
button uses theflights$
observable instead of the flights array. A very simple way to accomplish this is using theasync
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.
-
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.
-
Open your file
flight-app/src/app/+state/index.ts
, import therouterReducer
from@ngrx/router-store
and register it:import { routerReducer } from '@ngrx/router-store'; [...] export const reducers: ActionReducerMap<State> = { router: routerReducer };
-
In your
app.module
, configure theStoreRouterConnectingModule
as shown below:import { RouterState } from '@ngrx/router-store'; [...] @NgModule({ imports: [ [...] StoreRouterConnectingModule.forRoot({stateKey: 'router', routerState: RouterState.Minimal }) ], [...] }) export class AppModule {}
-
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:
- Install ngrx-immer:
npm i ngrx-immer immer
- Lookup its readme: https://www.npmjs.com/package/ngrx-immer
- Update your reducer so that it uses
immerOn
instead ofon
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';