-
Notifications
You must be signed in to change notification settings - Fork 1
State Management in Angular
Topics Covered
- New Course with New Syntax
- When to use Redux
- Application State
- NgRx Redux
- Creating Reducer
- Adding Action
- Adding NgRx Store
- Selecting State
- Dispatching Action
- Dispatching Multiple Actions
- Updating and Deleting Items
- Type Definition for State
- Subscribing and Pushing Data to State
- Creating One Root State for Entire Application
- Note on using Actions
- NgRx Side Effects
- Creating Side Effect
- Redux Dev Tool
- Alternate Syntax
Redux is not great for making simple things quickly. It’s great for making really hard things simple - Jani Eväkallio
In the official Redux docs you can read this (the same as for Redux/React apps applies to NgRX/Angular apps): "When should I use Redux?"
The need to use Redux should not be taken for granted. As Pete Hunt, one of the early contributors to React, says:
"You'll know when you need Flux. If you aren't sure if you need it, you don't need it."
Similarly, Dan Abramov, one of the creators of Redux, says:
"I would like to amend this: Don't use Redux until you have problems with vanilla React."
In general, use Redux when you have reasonable amounts of data changing over time, you need a single source of truth, and you find that approaches like keeping everything in a top-level React component's state are no longer sufficient.
However, it's also important to understand that using Redux comes with tradeoffs. It's not designed to be the shortest or fastest way to write code. It's intended to help answer the question "When did a certain slice of state change, and where did the data come from?", with predictable behavior. It does so by asking you to follow specific constraints in your application: store your application's state as plain data, describe changes as plain objects, and handle those changes with pure functions that apply updates immutably. This is often the source of complaints about "boilerplate". These constraints require effort on the part of a developer, but also open up a number of additional possibilities (such as store persistence and synchronization).
In the end, Redux is just a tool. It's a great tool, and there are some great reasons to use it, but there are also reasons you might not want to use it. Make informed decisions about your tools, and understand the tradeoffs involved in each decision.
References: When Should I use Redux, You might not need Redux - written by Redux creator Dan Abramov
-
A state is just the representation of your data at a given time i.e. status of your data at time t.
-
Example: You've an application in which a service would be there to manage the core data. You have multiple components that produces or utilized that data. This data is important for your application as it is controlling what's visible on the screen, that is your application state and this application state is lost whenever your application refreshes
-
You can resolve this by adding a backend service which is persistent and stores the state of your application at regular intervals. Each time your app loads or gets refreshed it could pull the data from persistent service and display that to user.
-
Now what's the problem with this approach? Simpler / smaller apps are fine with how we already manage state, by using components and services. If you have an app that becomes bigger and bigger and your state depends on many components at the same time and many components depend on the service and so on, then you could end up in state management nightmare.
-
It's hard to maintain the app because it's hard for you to tell where exactly am I managing this piece of information and maybe even changing a piece of information could accidentally break your code.
-
One solution is to use RxJs: Observables that allows us to create a streamlined state management experience. With RxJS:
- we can react to user events or to application events like some data fetching
- we can react to state changing event like an event where we want to update some data or some information in our app by using observables
- we can emit or next a new data piece there and maybe use operators to even transform data in the way we want it and then listen to such state changes in other parts of the application where we need it to then update the UI.
-
The RxJS driven approach has some issues like:
- your state can be updated from anywhere because you maybe failed to set up a clear flow of data.
- your application state is mutable i.e. there might be cases where your state is intended to change, your code in there might not update state of data like example if you only change a property of an object, the overall object didn't change and therefore such a state change might not get picked up.
- handling side effects i.e. things like HTTP requests, it's unclear where this should happen - should you write the code for sending them in a component? Should you do it in a service?
- It does not enforces a specific strict pattern which we should follow to avoid above problems
-
That is where NgRx: Redux will be helpful. Redux is a state management pattern, it's also a library that helps you implement that pattern into any application.
-
The idea behind Redux is that you have one central store in your entire application that holds your application state. So think of that as a large JavaScript object that contains all the data and different services and components, can still interact with each other but they receive their state from that store, so that store is the single source of truth that manages the entire application state.
-
State is not just received, you sometimes also need to change the state, and for that in the Redux world, you dispatch so-called actions. An action is the end also just a Javascript object with an identifier basically identifying the kind of action you want to perform and optionally if that action needs some extra data to complete then you can have that data in the action that is supported by Redux.
-
Now that action doesn't directly reach the store, instead it reaches a so-called reducer. Now a reducer is just a function that gets the current state which is stored in the store and the action as a input, passed in automatically by the Redux library.
-
You can have a look at the action identifier to find out which kind of action it is, for example add a recipe or delete the recipe and then perform code on the state which you also got as an argument to update that state, however in an immutable way i.e. by copying it and then changing the copy because ultimately, the reducer returns a new state, so it returns a copy of the old state that will have been changed according to the action and that state which is returned by the reducer then is forwarded to the application store where this reduced state, again the state was edited immutably, so without touching the old state instead copying it and then editing the copy, so where this reduced state is then overwriting the old state of the application.
-
That is the Redux pattern and as you can see, it enforces a pretty clean and clear flow of data. We only have one place in the app where we store our data and we only edit it through actions and reducers and we received the state through subscriptions which we can setup.
-
Now if you were using Angular, you could also use the Redux library, it's not restricted to be used with ReactJS only but NgRx is in the end just Angular's implementation of Redux.
-
NgRx for Redux comes with
- injectable services so that you can easily access your application store in any part of your app by simply injecting it.
- it also embraces RxJS and observables, so all the state is managed as one large observable
- it handles side effects i.e. helps in managing HTTP requests
Point to Remember In Redux, you only may execute synchronous code, so you must not and you can't send any asynchronous HTTP requests from inside a reducer function.
-
Installing core NgRx package:
npm install @ngrx/store
-
Reducer is a simple function that takes initial state and action to be performed on state as input.
export function shoppingListReducer(state, action) {}
-
Initial state is basically data present when the app loads initially. We could have some default values stored in an array as initial state of data:
const initialState = { ingredients: [ new Ingredients('paneer', 4), new Ingredients('paratha', 1), new Ingredients('mix-veggies', 4), new Ingredients('spices', 3) ] };
-
We can assign this initial state to reducer function
export function shoppingListReducer(state = initialState, action) {}
- This is ES6 syntax wherein you can assign a default/initial value to an function parameter if it is set to empty or null.
-
Now we need to perform operations based on input action type. So we can associate action to type
Action
from@ngrx/store
and perform multiple operations based on action type.- Action should always be performed on copy state and not the original state and after performing operation we should always return the updated state.
import { Action } from '@ngrx/store'; ... export function shoppingListReducer(state = initialState, action: Action) { switch (action.type) { case 'ADD_INGREDIENT': return { ...state, ingredients: [...state.ingredients, action] }; } }
-
Actions always needs to be dispatched to the Reducer with certain identifier that states the type of action that needs to be performed on State and optionally a payload i.e. additional data that will be required to update state.
-
So we need to first define our Action with all required parameters. In the newly created file, the first step would be to export all identifiers. In our example that will be 'ADD_INGREDIENT':
export const ADD_INGREDIENT = 'ADD_INGREDIENT';
and make adjustments in the Reducer accordingly:
import { ADD_INGREDIENT } from './shopping-list.actions'; ... case 'ADD_INGREDIENT': ...
-
The second step is to export something that describes our Action. So here we can create a class
AddIngredient
, this name specifies what our Action actually does and implement the interfaceAction
from@ngrx/store
export class AddIngredient implements Action {}
-
The
action
interface forces you to add variabletype
of string. This will act as the identifier of your action. You can append this withreadonly
property which ensures that this is read-only and cannot be over-written. -
We can add second property called
payload
that will hold the additional data required to update state.export class AddIngredient implements Action { readonly type: string = ADD_INGREDIENT; payload: Ingredients; }
-
Fix the imports as per newly prepared Action
- Since we are exporting more than 1 thing in action class which needs to be imported here, look mat the syntax of multi-export, here
ShoppingListActions
will act as object that holds all the exports. - The
action: Action
will be changed toaction: ShoppingListActions.AddIngredient
to specify our custom action - case
case 'ADD_INGREDIENT'
will be changed tocase ShoppingListActions.ADD_INGREDIENT
- When we add ingredients to the array, we have new property
payload
in our Action that deals with additional data to be handled, soaction
will be replaced withaction.payload
import { Ingredients } from '../../shared/ingredients.model'; import * as ShoppingListActions from './shopping-list.actions'; ... export function shoppingListReducer(state = initialState, action: ShoppingListActions.AddIngredient) { switch (action.type) { case ShoppingListActions.ADD_INGREDIENT: return { ...state, ingredients: [...state.ingredients, action.payload] }; } }
- Since we are exporting more than 1 thing in action class which needs to be imported here, look mat the syntax of multi-export, here
-
Now to add Store, we go to
app.module.ts
file and importStoreModule
from@ngrx/store
. This moduleimport { StoreModule } from '@ngrx/store';
-
We later add this to
imports
section specifying which Reducers are involved so-for using valid JavaScript object.StoreModule.forRoot({shoppingList: shoppingListReducer})
- Here
shoppingList
is the label/key describing the Reducer - Here
shoppingListReducer
is the name of our Reducer
- Here
-
With this, NgRx will take that reducer into account and set up an application store for us where it registers this reducer, so now any actions that are dispatched will reach that reducer.
-
In shopping-list-component, we first create instance of Store from
@ngrx/store
:constructor( private shoppingListService: ShoppingListService, private store: Store<{ shoppingList: { ingredients: Ingredients[] } }> ) { }
- here we are supposed to provide key which will be the label we used in app.module.ts while defining Store module
StoreModule.forRoot({shoppingList: shoppingListReducer})
- its type will be the label we used for defining initial state i.e.
const initialState = { ingredients: [] }
- here we are supposed to provide key which will be the label we used in app.module.ts while defining Store module
-
Now we define a variable that will hold data return from state
ingredientsModelArray: Observable<{ ingredients: Ingredients[] }>; ngOnInit(): void { this.ingredientsModelArray = this.store.select('shoppingList'); }
-
As the state returns Observable, we can make use of
async
pipe to fetch details. Here theasync
will to return the state object which in turn has propertyingredients
which can be used to loop and fetch values<a class="list-group-item" style="cursor: pointer" *ngFor="let ingredient of (ingredientsModelArray | async).ingredients; let i = index" (click)="onEditIngredient(i)"> {{ ingredient.name }} ({{ ingredient.amount }}) </a>
-
Finally we add a default case to our Reducer so that it return initial state when there is nothing in place to return. When the app gets loaded first time, action defaults to
"@ngrx/store/init"
export function shoppingListReducer(state = initialState, action: ShoppingListActions.AddIngredient) { switch (action.type) { case ShoppingListActions.ADD_INGREDIENT: return { ...state, ingredients: [...state.ingredients, action.payload] }; default: return initialState; } }
-
We need to dispatch actions to the places that modify state of data like add/update. So we edit
shopping-edit-component
that deals with insertion and updating of data -
We first create instance of Store
import { Store } from '@ngrx/store'; import * as ShoppingListActions from '../store/shopping-list.actions'; ... constructor( private shoppingListService: ShoppingListService, private store: Store<{ shoppingList: { ingredients: Ingredients[] } }> ) { }
-
While adding ingredients we dispatch the Action
this.store.dispatch(new ShoppingListActions.AddIngredient(new Ingredients(name, Number(amount))));
-
For above statement to work we need to modify our Action such that the
payload
property should now be accepted as constructor so that we can pass Ingredients as parameter like above. Note it should be public else it won't be accessible.export class AddIngredient implements Action { readonly type: string = ADD_INGREDIENT; constructor (public payload: Ingredients) {} }
-
A use case we can add multiple ingredients i.e. dispatching multiple actions.
-
First step will be to modify our Action so that it can accept multiple inputs:
export const ADD_INGREDIENTS = 'ADD_INGREDIENTS'; ... export class AddIngredients implements Action { readonly type = ADD_INGREDIENTS; constructor (public payload: Ingredients[]) { } } ... export type ShoppingListActions = AddIngredient | AddIngredients;
-
Pay Attention: we have removed
string
type from our const becauseexport const ADD_INGREDIENTS: string = 'ADD_INGREDIENTS'; /* changes to */ export const ADD_INGREDIENTS = 'ADD_INGREDIENTS'; /* other valid option */ export const ADD_INGREDIENTS: 'ADD_INGREDIENTS' = 'ADD_INGREDIENTS';
-
Pay Attention: we have removed
-
Second step is to update Reducer,
export function shoppingListReducer(state = initialState, action: ShoppingListActions.ShoppingListActions) { switch (action.type) { ... case ShoppingListActions.ADD_INGREDIENTS: return { ...state, ingredients: [...state.ingredients, ...action.payload] }; ...
- Above
case
statement will result into error. The reason is we tell TypeScript that the action type of the action we're getting in the reducer isShoppingListActions ADD_INGREDIENTS
. - Now that was true when we only had only one action but now we have two actions
ShoppingListActions ADD_INGREDIENT
andShoppingListActions ADD_INGREDIENTS
and we can't tell whether the action that reaches this reducer is actually theADD_INGREDIENT
or theADD_INGREDIENTS
action. - To resolve this, In Actions, we export a union type
export type ShoppingListActions = AddIngredient | AddIngredients;
- Now
action.payload
here should not be added like this because it will be an array of ingredients, so if we add it like this, then we add a array to an array and hence we have a nested array. I want to add the elements of that payload array to the outer array and therefore here, we should also use the spread operator to pull these elements inaction.payload
out of this array and add them to this ingredients array.
- Above
-
Last step is to dispatch action in our service
import { Store } from '@ngrx/store'; import * as ShoppingListActions from '../shopping-list/store/shopping-list.actions'; ... constructor( private shoppingListService: ShoppingListService, private store: Store<{ shoppingList: { ingredients: Ingredients[] } }> ) {} ... this.store.dispatch(new ShoppingListActions.AddIngredients(ingredientsArray));
-
First step is to modify actions to include identifiers for both the actions
export const UPDATE_INGREDIENTS = 'UPDATE_INGREDIENTS'; export const DELETE_INGREDIENTS = 'DELETE_INGREDIENTS'; ... export class UpdateIngredients implements Action { readonly type = UPDATE_INGREDIENTS; constructor (public payload: {index: number, ingredient: Ingredients}) { } } export class DeleteIngredients implements Action { readonly type = DELETE_INGREDIENTS; constructor (public payload: number) { } } ... export type ShoppingListActions = AddIngredient | AddIngredients | UpdateIngredients | DeleteIngredients;
-
Then modify reducer
case ShoppingListActions.UPDATE_INGREDIENTS: // fetch current ingredient const oldIngredient = state.ingredients[action.payload.index]; // immutable change - create new object, copy existing data, add new data from payload so the updatedData consist data to be updated const updatedIngredient = { ...oldIngredient, ...action.payload.ingredient }; // we need array in template, fetch existing ingredients const updatedIngredients = [...state.ingredients]; // update array with new ingredient updatedIngredients[action.payload.index] = updatedIngredient; return { ...state, ingredients: updatedIngredients }; case ShoppingListActions.DELETE_INGREDIENTS: return { ...state, // filter returns a new array with filtered items ingredients: state.ingredients.filter((currentIngredient, currentIngredientIndex) => { return currentIngredientIndex !== action.payload; }) };
-
Finally Dispatch the actions
this.store.dispatch(new ShoppingListActions.UpdateIngredients({ index: this.editedIndexNumber, ingredient: new Ingredients(name, Number(amount)) })); ... this.store.dispatch(new ShoppingListActions.DeleteIngredients(this.editedIndexNumber));
-
Initial State is:
const initialState = { ingredients: [ new Ingredients('paneer', 4), new Ingredients('paratha', 1), new Ingredients('mix-veggies', 4), new Ingredients('spices', 3) ] };
- This state does not have any type definition also if we add any new properties to initial state we need to make update to multiple component and services where we are using this state.
-
To make it more verbose we should add a type definition to our state at one place and use that everywhere. One advantage would be if we want to change the contents within state, we need to update that only in one place and not across the project.
-
For this we create an interface and assign that as type to the initial state:
export interface State { ingredients: Ingredients[]; editedIngredient: Ingredients; editedIngredientIndex: number; } ... const initialState: State = { ... }
-
As of now we just have one state in our project and that is also defined in our app.module
StoreModule.forRoot({shoppingList: shoppingListReducer})
- In future we could have many such states associated, so we can create one common application wide interface to handle type definitions if all state (be careful with the name, use same name as used in app.module)
export interface AppState { shoppingList: State }
- In future we could have many such states associated, so we can create one common application wide interface to handle type definitions if all state (be careful with the name, use same name as used in app.module)
-
We can later use this across our components and services:
// below initial snippet private store: Store<{ shoppingList: { ingredients: Ingredients[] } }> // changes to... import * as fromShoppingList from '../store/shopping-list.reducer'; ... private store: Store<fromShoppingList.AppState>
/* subscribing from service */
this.subscription = this.shoppingListService.ingredientEdit.subscribe((index: number) => {
this.editedIndexNumber = index;
this.editMode = true;
this.editedItem = this.shoppingListService.getIngredient(index);
});
/* subscribing to state */
this.subscription = this.store.select('shoppingList').subscribe(stateData => {
if (stateData.editedIngredientIndex > -1) {
this.editedIndexNumber = stateData.editedIngredientIndex;
this.editMode = true;
this.editedItem = stateData.editedIngredient;
}
});
/* pushing data to service */
this.shoppingListService.ingredientEdit.next(id);
/* pushing data to state */
this.store.dispatch(new ShoppingListActions.StartEdit(id));
/* unsubscribe */
ngOnDestroy(): void {
this.subscription.unsubscribe();
this.store.dispatch(new ShoppingListActions.StopEdit());
}
Initial Project had just one State and one corresponding declaration in App Module file
/* State */
export interface State {
ingredients: Ingredients[];
editedIngredient: Ingredients;
editedIngredientIndex: number;
}
...
/* App Module declaration */
StoreModule.forRoot({
shoppingList: shoppingListReducer
})
When the number of states increases, corresponding entries also increases
/* State 1: within shopping-list.reducer.ts */
export interface State {
ingredients: Ingredients[];
editedIngredient: Ingredients;
editedIngredientIndex: number;
}
/* State 2: within auth.reducer.ts */
export interface State {
user: User;
}
...
/* App Module declaration */
StoreModule.forRoot({
shoppingList: shoppingListReducer,
auth: authReducer
})
This increases overall complexity and makes it tough to maintain them as well. We can create a single state application wide that will control states of all components and services. For this
- We first create a separate file and define interface that hold the state of all the components and services
import * as fromShoppingList from '../shopping-list/store/shopping-list.reducer'; import * as fromAuth from '../auth/store/auth.reducer' import { ActionReducerMap } from '@ngrx/store'; export interface AppState { shoppingList: fromShoppingList.State, auth: fromAuth.State } export const appReducer: ActionReducerMap<AppState> = { shoppingList: fromShoppingList.shoppingListReducer, auth: fromAuth.authReducer };
- We then create a Reducer of type
ActionReducerMap<T>
and pass-in our interface as its type. This reducer is basically an object that needs list of reducers in our application. Here key is the thing that you defined in yourAppState
interface and its value is the corresponding Reducers associated with those components/services - We then update our App Module with newly created Reducer type
import * as fromAuth from './store/app.reducer'; ... StoreModule.forRoot(fromAuth.appReducer)
- We then use this particular state in all our components and services
import * as fromApp from '../store/app.reducer'; ... constructor( private store: Store<fromApp.AppState> ) { } ngOnInit(): void { this.ingredientsModelArray = this.store.select('shoppingList'); this.authentication = this.store.select('auth'); }
-
When NgRx starts up, it sends one initial action to all reducers and since this action has an identifier we don't handle anywhere, we make it into the default case, therefore we return the state and since we have no prior state when this first action is emitted, we therefore take this initial state.
-
This initial action dispatched automatically reaches all reducers. Well that is not just a case for the initial action, any action you dispatch, even your own ones, not just that initial one, so any action you dispatch by calling dispatch always reaches all reducers.
-
It does not just reach the authReducer because you are dispatching it in the authService, how would NgRx know that this is your intention? It would be dangerous if that would be the result because that means you could never reach another reducer with actions dispatched here in the authService and there certainly are scenarios where you might want to reach another reducer. So therefore, any action you dispatch always reaches all reducers.
-
This has an important implication, it's even more important that you always copy the old state here and return this because if a shoppingList related action is dispatched, it still reaches the authReducer. In there (authReducer) of course, we have no case that would handle that action, hence all these code snippets here don't kick in but therefore the default case kicks in and handles this and there, we absolutely have to return our current state, otherwise we would lose that or cause an error.
-
The next important thing is that since our dispatched actions reach all reducers, you really have to ensure that your identifiers here are unique across the entire application because these auth-related actions here are not just handled by the authReducer, they also reach the shoppingListReducer.
-
General Syntax of writing any action
/* used in this guide */ export const LOGIN = 'LOGIN'; export const ADD_INGREDIENT = 'ADD_INGREDIENT'; /* NgRx recommended approach '[Action Name] Identifier' */ export const LOGIN = '[AUTH] Login'; export const ADD_INGREDIENT = '[Shopping List] Add Ingredient';
-
Side effects are basically parts in your code where you run some logic but that's not so important for the immediate update of the current state. For example here, the HTTP request
-
To work with these side effects there is a separate package -
@ngrx/effects
that gives us tools for elegantly working with side effects between actions which we dispatch and receive so that we can keep our reducers clean and still have a good place for managing these side effects.
-
Create a file
auth.effects.ts
that exports a class with same name as file nameexport class AuthEffects {}
-
We need to inject dependency
Actions
(previously we usedAction
from@ngrx/store
) that helps us to create a stream of actions which we need to handle async requestimport { Actions, ofType } from '@ngrx/effects'; @Injectable() export class AuthEffects { constructor ( private actions$: Actions ) {} }
- Here
$
behind action variable is a notation to specify that given variable/property is an Observable - We need to add
@Injectable()
as later on we will add http and routing dependencies via constructor.
- Here
-
We create a property that would basically deal with handling side effect. Since we are working on HTTP login effect, we create a property
authLogin
-
In our actions file, we export a new action type
LOGIN_START
that will mark as an async http action has just begun &LOGIN_FAIL
action will deal with error cases. -
We also add corresponding classes for these actions.
LoginStart
needs credentials in payload to create user object and complete login process andLoginFail
needs a message that can be displayed as error message on screenexport const LOGIN_START = '[Auth] Login Start'; export const LOGIN_FAIL = '[Auth] Login Fail'; ... export class LoginStart implements Action { readonly type = LOGIN_START; constructor( public payload: { email: string, password: string }) {} } export class LoginFail implements Action { readonly type = LOGIN_FAIL; constructor( public payload: string ) {} } ... export type AuthActions = Login | Logout | LoginStart | LoginFail;
-
Update corresponding Action handling in our Reducer as well. When Login starts, we need loading spinner and error is null at this stage. If Login fails, we stop loading and display error message.
case AuthActions.LOGIN_START: return { ...state, authError: null, loading: true } case AuthActions.LOGIN_FAIL: return { ...state, authError: action.payload, loading: false }
-
Add import to app.module file
imports: [ EffectsModule.forRoot([AuthEffects]) ],
-
Back to our effects file, we now listen to newly created action from our stream
@Effect() authLogin = this.actions$.pipe( ofType(AuthActions.LOGIN_START) );
- We should not subscribe to
actions$
, Angular does that for us, we should attachpipe()
and perform other operations -
ofType()
is an operator provided by@ngrx/effects
that help us to listen to filtered actions only. - We can provide multiple comma separated actions as well...
-
@Effect()
is ngrx special decorator to let compiler know we are creating an effect here
- We should not subscribe to
-
We now make HTTP calls within our effect.
@Effect() authLogin = this.actions$.pipe( ofType(AuthActions.LOGIN_START), switchMap((authData: AuthActions.LoginStart) => { return this.http .post<AuthResponsePayload>('url') .pipe( map(responseData => { // response handling return new AuthActions.Login({email, id, token, expirationDate}); }), catchError(errorRes => { // error handling return of(new AuthActions.LoginFail(errorMessage)); }) ); }) );
- Effect will only start when we have Action of type Login for Auth.
- It will process the request and will either yield result or throw error. In either case we need to return observable.
- The
map()
method of rxjs returns an observable of whatever you process within it. - For
catchError()
you need to wrap message inside observable by creating new observable usingof()
- This observable - pass/fail will be passed to
switchMap()
which will return this toauthLogin
that can be later used in our services and components to carry out appropriate operations.
-
The reason for returning observable from
switchMap()
viacatchError()
andmap()
is that:- effects here is an ongoing observable stream, this must never die, at least not as long as our application is running and therefore, if we would catch an error here by adding
catchError()
as a next step afterswitchMap()
, this entire observable stream will die. - which means that trying to login again will simply not work because this effect here will never react to another dispatched login start event (app will not respond, user will keep on hitting login button but nothing will happen) because this entire observable is dead and therefore, errors have to be handled on a different level.
- most important - we have to return a non-error observable so that our overall stream doesn't die and since
switchMap()
returns the result of this inner observable stream as a new observable to the outer chain here, returning a non-error observable incatchError()
is crucial, so that this erroneous observable in here still yields a non-error observable which is picked up byswitchMap()
which is then returned to this overall stream, to this overall observable chain.
- effects here is an ongoing observable stream, this must never die, at least not as long as our application is running and therefore, if we would catch an error here by adding
-
We then dispatch these actions
this.store.dispatch( new AuthActions.LoginStart({email, password}) );
-
After successful login we want user to navigate to home page. Important - Navigation are also kind of side effects and must be handled accordingly
@Effect({ dispatch: false }) authSuccess = this.actions$.pipe( ofType(AuthActions.LOGIN), tap(() => { this.router.navigate(['/']); }) );
- this is an effect which does not dispatch a new action at the end. Your effects do that, they typically return an observable which holds a new effect which should be dispatched, but this effect doesn't and to let NgRx effect know about that and avoid errors, you have to pass an object
dispatch: false
to your@Effect()
decorator. - Once the Login Action is successful, user will navigate to desired path!
- this is an effect which does not dispatch a new action at the end. Your effects do that, they typically return an observable which holds a new effect which should be dispatched, but this effect doesn't and to let NgRx effect know about that and avoid errors, you have to pass an object
-
Install 2 packages as dev dependencies:
store-devtools
androuter-store
-
In app.module, update following configurations:
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreRouterConnectingModule } from '@ngrx/router-store'; ... imports: [ StoreDevtoolsModule.instrument({ logOnly: environment.production }), StoreRouterConnectingModule.forRoot() ],
-
Download Redux DevTools plugin from Google store and restart browser. After restarting, In Developers tools you'll see new option Redux
- By Jost - from Udemy
Recently the official NgRX docs have completely changed. Now they don't mention the traditional syntax used by Max any longer. They have completely switched to NgRX's new createAction()
/createReducer()
/createEffect()
syntax, even though the "old" syntax is still fully working and not deprecated (you will find the "old" syntax here.
This change came very surprisingly, and with a delay after the Angular 8 release which has been the basis for Max' course updates.
With the new syntax we can avoid some of the traditional boilerplate code, and the actions can be easier tracked in the whole application (with the traditional approach we have to use the actions' string types in the reducers and the effects' ofType()
, but the TS types of the actions themselves in the other parts of an app).
In spite of this change the structure of the code remains exactly the same (since it's the Redux pattern in the end), but it might be very confusing at first sight when you want to compare the course code with what you will find in the new docs.
Here is a description how you can transform the final code of this section into the new syntax, exactly preserving the app's functionality. More features (like createSelector()
, createFeatureSelector()
, createEntityAdapter()
etc.) can be found in the NgRX docs, but these are beyond the scope of this course which is about Angular, not NgRX in depth.
If you want to switch to the new syntax (even though the old one is still valid) ...
- replace the action files by these ones:
auth.actions.ts
import {createAction, props} from '@ngrx/store';
export const loginStart = createAction('[Auth] Login Start', props<{ email: string; password: string }>());
export const signupStart = createAction('[Auth] Signup Start', props<{ email: string; password: string }>());
export const authenticateSuccess = createAction('[Auth] Authenticate Success', props<{ email: string; userId: string; token: string; expirationDate: Date; redirect: boolean }>());
export const authenticateFail = createAction('[Auth] Authenticate Fail', props<{ errorMessage: string }>());
export const clearError = createAction('[Auth] Clear Error');
export const autoLogin = createAction('[Auth] Auto Login');
export const logout = createAction('[Auth] Logout');
recipe.actions.ts
import { createAction, props } from '@ngrx/store';
import { Recipe } from '../recipe.model';
export const addRecipe = createAction('[Recipe] Add Recipe', props<{ recipe: Recipe }>());
export const updateRecipe = createAction('[Recipe] Update Recipe', props<{ index: number, recipe: Recipe }>());
export const deleteRecipe = createAction('[Recipe] Delete Recipe', props<{ index: number }>());
export const setRecipes = createAction('[Recipe] Set Recipes', props<{ recipes: Recipe[] }>());
export const fetchRecipes = createAction('[Recipe] Fetch Recipes');
export const storeRecipes = createAction('[Recipe] Store Recipes');
shopping-list.actions.ts
import { createAction, props } from '@ngrx/store';
import { Ingredient } from '../../shared/ingredient.model';
export const addIngredient = createAction('[Shopping List] Add Ingredient', props<{ ingredient: Ingredient }>());
export const addIngredients = createAction('[Shopping List] Add Ingredients', props<{ ingredients: Ingredient[] }>());
export const updateIngredient = createAction('[Shopping List] Update Ingredient', props<{ ingredient: Ingredient }>());
export const deleteIngredient = createAction('[Shopping List] Delete Ingredient');
export const startEdit = createAction('[Shopping List] Start Edit', props<{ index: number }>());
export const stopEdit = createAction('[Shopping List] Stop Edit');
- replace the reducer files by these ones:
auth.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';
import { User } from '../user.model';
import * as AuthActions from './auth.actions';
export interface State { user: User; authError: string; loading: boolean; }
const initialState: State = { user: null, authError: null, loading: false };
export function authReducer(authState: State | undefined, authAction: Action) {
return createReducer(
initialState,
on(AuthActions.loginStart, AuthActions.signupStart, state => ({...state, authError: null, loading: true})),
on(AuthActions.authenticateSuccess, (state, action) => ({ ...state, authError: null, loading: false, user: new User(action.email, action.userId, action.token, action.expirationDate)})),
on(AuthActions.authenticateFail, (state, action) => ({ ...state, user: null, authError: action.errorMessage, loading: false})),
on(AuthActions.logout, state => ({...state, user: null })),
on(AuthActions.clearError, state => ({...state, authError: null })),
)(authState, authAction);
}
recipe.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';
import { Recipe } from '../recipe.model';
import * as RecipesActions from '../store/recipe.actions';
export interface State { recipes: Recipe[]; };
const initialState: State = { recipes: []};
export function recipeReducer(recipeState: State | undefined, recipeAction: Action) {
return createReducer(
initialState,
on(RecipesActions.addRecipe, (state, action) => ({ ...state, recipes: state.recipes.concat({ ...action.recipe }) })),
on(RecipesActions.updateRecipe, (state, action) => ({ ...state, recipes: state.recipes.map((recipe, index) => index === action.index ? { ...action.recipe } : recipe) })),
on(RecipesActions.deleteRecipe, (state, action) => ({ ...state, recipes: state.recipes.filter((recipe, index) => index !== action.index) })),
on(RecipesActions.setRecipes, (state, action) => ({ ...state, recipes: [...action.recipes] }))
)(recipeState, recipeAction);
}
shopping-list.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';
import { Ingredient } from '../../shared/ingredient.model';
import * as ShoppingListActions from './shopping-list.actions';
export interface State { ingredients: Ingredient[]; editIndex: number; }
const initialState: State = { ingredients: [new Ingredient('Apples', 5), new Ingredient('Tomatoes', 10)], editIndex: -1 };
export function shoppingListReducer(shoppingListState: State | undefined, shoppingListAction: Action) {
return createReducer(
initialState,
on(ShoppingListActions.addIngredient, (state, action) => ({ ...state, ingredients: state.ingredients.concat(action.ingredient) })),
on(ShoppingListActions.addIngredients, (state, action) => ({ ...state, ingredients: state.ingredients.concat(...action.ingredients) })),
on(ShoppingListActions.updateIngredient, (state, action) => ({ ...state, editIndex: -1, ingredients: state.ingredients.map((ingredient, index) => index === state.editIndex ? { ...action.ingredient } : ingredient) })),
on(ShoppingListActions.deleteIngredient, state => ({ ...state, editIndex: -1, ingredients: state.ingredients.filter((ingredient, index) => index !== state.editIndex) })),
on(ShoppingListActions.startEdit, (state, action) => ({ ...state, editIndex: action.index })),
on(ShoppingListActions.stopEdit, state => ({ ...state, editIndex: -1 }))
)(shoppingListState, shoppingListAction);
}
- change these places in the effect files:
auth.effects.ts
authSignup$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.signupStart),
switchMap(action => {
...
email: action.email,
password: action.password,
...
);
authLogin$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.loginStart),
switchMap(action => {
...
email: action.email,
password: action.password,
...
);
authRedirect$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.authenticateSuccess),
tap(action => action.redirect && this.router.navigate(['/']))
), { dispatch: false }
);
autoLogin$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.autoLogin),
...
);
authLogout$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.logout),
...
{ dispatch: false }
);
recipe.effects.ts
fetchRecipes$ = createEffect(() =>
this.actions$.pipe(
ofType(RecipesActions.fetchRecipes),
...
);
storeRecipes$ = createEffect(() =>
this.actions$.pipe(
ofType(RecipesActions.storeRecipes),
...
{ dispatch: false }
);
- make a global search (Ctrl + Shift + F) for dispatch and change the related action syntax in those places:
-
remove the new keyword
-
change the action name to lowerCamelCase
-
change the passed parameter to an object
E.g.:
this.store.dispatch(new ShoppingListActions.StartEdit(index));
... becomes ...
this.store.dispatch(ShoppingListActions.startEdit({index}));
... and ...
this.store.dispatch(new RecipesActions.AddRecipe(this.recipeForm.value));
... becomes ...
this.store.dispatch(RecipesActions.addRecipe({recipe: this.recipeForm.value}));
Important: Don't forget to apply all changes to the remaining actions in the two effect files as well, even though you won't find them via a search for dispatch there!
- and please note:
-
Inside the reducers I replaced Max' transformation logic by one-liners, using concat/map/filter. This is not related to the new NgRX syntax; it just makes this thread a bit shorter ;)
-
In my code I removed the editedItem property from the shopping list state, since it's kind of redundant. It would only be used in one place of the app (shopping-edit.component.ts), and there it can be easily accessed via its index:
...
.subscribe(stateData => {
const index = stateData.editIndex;
if (index > -1) {
this.editedItem = stateData.ingredients[index];
...
- In the Resolver class don't forget to change the generic type of the Resolve interface from
Resolve<Recipe[]>
toResolve<{recipes: Recipe[]}>
, and the return value in the else case fromrecipes
to{recipes}
.