Angular ngrx: Difference between revisions
(46 intermediate revisions by the same user not shown) | |||
Line 157: | Line 157: | ||
export const initCurrentProduct = createAction('[Product] Init current product') | export const initCurrentProduct = createAction('[Product] Init current product') | ||
</syntaxhighlight> | </syntaxhighlight> | ||
===Properties | ===Properties=== | ||
Some actions will need properties to work. In the above example this would be setCurrentProduct so we need to add it as a props to pass. In Angular we do this. | Some actions will need properties to work. In the above example this would be setCurrentProduct so we need to add it as a props to pass. In Angular we do this. | ||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
Line 168: | Line 168: | ||
) | ) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
===Complex Actions=== | |||
Some actions need to be broken down in smaller actions. The point to breaking them down is probably for debugging so we can see where it went wrong. For example | |||
* Outgoing | |||
** dispatch load something | |||
* Incoming | |||
** dispatch got something | |||
** dispatch got error | |||
==Building a Reducer== | ==Building a Reducer== | ||
===Reducer with no Parameters=== | |||
The function return a copy of the state '''not''' a modified state by ... (spreading) the original state and adding or replacing the new state. | The function return a copy of the state '''not''' a modified state by ... (spreading) the original state and adding or replacing the new state. | ||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
import { createReducer, on, createAction } from '@ngrx/store'; | import { createReducer, on, createAction } from '@ngrx/store'; | ||
import * as ProductActions from './product.actions' | |||
export const productReducer = createReducer<ProductState>( | export const productReducer = createReducer<ProductState>( | ||
initialState, | initialState, | ||
on( | on(ProductActions.toggleProductCode, (state) : ProductState => { | ||
return { | return { | ||
...state, | ...state, | ||
Line 184: | Line 193: | ||
) | ) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
Do not forget to update the module to have the reducer assigned. | Do not forget to update the module to have the reducer assigned. | ||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
Line 189: | Line 199: | ||
StoreModule.forFeature('products', productReducer) | StoreModule.forFeature('products', productReducer) | ||
... | ... | ||
</syntaxhighlight> | |||
===Reducer with Parameters=== | |||
To use a reducer which has parameters we simply deconstruct the second parameter on the on parameter. | |||
<syntaxhighlight lang="js"> | |||
on(ProductActions.setCurrentProduct, (state, params) : ProductState => { | |||
return { | |||
...state, | |||
currentProduct: params.product | |||
} | |||
}) | |||
</syntaxhighlight> | </syntaxhighlight> | ||
Line 224: | Line 244: | ||
... | ... | ||
import { State } from '../state/product.reducer'; | import { State } from '../state/product.reducer'; | ||
import * as ProductActions from './../state/product.actions' | |||
... | ... | ||
constructor( | constructor( | ||
Line 231: | Line 251: | ||
.. | .. | ||
checkChanged(): void { | checkChanged(): void { | ||
this.store.dispatch( | this.store.dispatch(ProductActions.toggleProductCode()) | ||
} | } | ||
Line 255: | Line 273: | ||
) | ) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
==NgRx Effects Library== | |||
Some definitions, | |||
*'''pure functions''' are function which return the same result given the same input | |||
*'''side effects''' are an operation which depends on or operates with an external source such as external state, devices, or an API | |||
Our goal is to find where to put things with side effects | |||
*Components should be as pure possible | |||
*Reducers are pure functions | |||
So we create an effects, effects | |||
*take an action, | |||
*do some work and, | |||
*dispatch a new action | |||
[[File:Ngrx effects.png|800px]] | |||
So the process for load products is | |||
*Component issues a dispatch action loadProducts | |||
*Action calls the reducer | |||
*Reducer cannot do async requests to calls the effect | |||
*Effect talks to the service and gets and response | |||
*Service passes the response to the effect | |||
*The Effect send the loadProductSuccess to the reducer | |||
*Reducer updates the Store | |||
*The Selector listening on the store gets the changes | |||
==Creating an Effects== | |||
'''Note''' ending a variable with $ (dollar) suffix is a convention in the community to denote an observable. | |||
===Installing=== | |||
<syntaxhighlight lang="bash"> | |||
ng add @ngrx/effects | |||
</syntaxhighlight> | |||
This amends the app.module.ts as follows | |||
<syntaxhighlight lang="js"> | |||
... | |||
import { EffectsModule } from '@ngrx/effects'; | |||
@NgModule({ | |||
imports: [ | |||
BrowserModule, | |||
... | |||
EffectsModule.forRoot([]) | |||
</syntaxhighlight> | |||
===Define a service=== | |||
This takes an action and and your service | |||
<syntaxhighlight lang="js"> | |||
@Injectable() | |||
export class ProductEffects { | |||
constructor( | |||
private actions$:Actions, | |||
private productService: ProductService) {} | |||
} | |||
</syntaxhighlight> | |||
===Create an Effect=== | |||
We create an effect in our product module call product.effects.ts | |||
<syntaxhighlight lang="js"> | |||
loadProducts$ = createEffect() => { | |||
return this.actions$.pipe( | |||
... | |||
} | |||
</syntaxhighlight> | |||
===Filter the Actions=== | |||
We listen for the appropriate action by using the ofType filter, in our case ProductActions.loadProducts | |||
<syntaxhighlight lang="js"> | |||
... | |||
ofType(ProductActions.loadProducts), | |||
... | |||
} | |||
</syntaxhighlight> | |||
===Map and call the Service=== | |||
<syntaxhighlight lang="js"> | |||
... | |||
mergeMap(action => | |||
this.productService.getProducts().pipe( | |||
... | |||
} | |||
</syntaxhighlight> | |||
===Return the new action=== | |||
Return the new action with data, in our case success | |||
<syntaxhighlight lang="js"> | |||
... | |||
map(products => | |||
ProductActions.loadProductSuccess({products}))) | |||
... | |||
} | |||
</syntaxhighlight> | |||
===Final Statement=== | |||
<syntaxhighlight lang="js"> | |||
import { ofType, createEffect, Actions } from '@ngrx/effects' | |||
... | |||
loadProducts$ = createEffect() => { | |||
return this.actions$.pipe( | |||
ofType(ProductActions.loadProducts), | |||
mergeMap(action => | |||
this.productService.getProducts().pipe( | |||
map(products => | |||
ProductActions.loadProductSuccess({products}))) | |||
) | |||
) | |||
}); | |||
} | |||
</syntaxhighlight> | |||
===Create Reducer Function=== | |||
Create the loadProductsSuccess Reducer Function | |||
<syntaxhighlight lang="js"> | |||
on(ProductActions.loadProductsSuccess, (state, action) : ProductState => { | |||
return { | |||
...state, | |||
products: action.products | |||
} | |||
}), | |||
</syntaxhighlight> | |||
===About RxJs Operators in Effects=== | |||
* switchMap Cancels the current subscription/request and can cause race conditions | |||
**Use for get requests or cancelable requests like searches (maybe read-only?) | |||
It cancels the current subscription if a new value is omitted. This means if someone dispatches a second save operation before the first actions HTTP request returns to the effect, the first request will be cancelled and save might not happen | |||
* concatMap Runs subscriptions/requests in order and is less performant | |||
** Use for get,post and put requests when order '''is''' important | |||
concatMap Runs subscriptions/requests in order and will wait for the last request to finish before starting the next. | |||
* mergeMap Runs subscription/requests in parallel | |||
** Use for get,put,post and delete methods when order is '''not''' important | |||
mergeMap Runs subscription/requests in parallel, it is faster than concatMap but does not guarantee order | |||
*exhaustMap Ignores all subsequent subscriptions/requests until it completes | |||
**Use for login when you do not want more requests until the initial one is complete | |||
exhaustMap Ignores all subsequent subscriptions/requests dispatched until the current request is complete. Use this when you do not want to queue up any more requests until the initial one is complete | |||
===Add Our Effect to Product Module=== | |||
Ezzy pezzy lemon squezzy | |||
<syntaxhighlight lang="js"> | |||
import { ProductEffects } from './state/products.effects'; | |||
import { EffectsModule } from '@ngrx/effects'; | |||
import { NgModule } from '@angular/core'; | |||
... | |||
@NgModule({ | |||
imports: [ | |||
SharedModule, | |||
RouterModule.forChild(productRoutes), | |||
StoreModule.forFeature('products', productReducer), | |||
EffectsModule.forFeature([ProductEffects]) | |||
], | |||
... | |||
</syntaxhighlight> | |||
===Using the Effect in the Component=== | |||
We need to | |||
* Create an observer to watch for product changes | |||
* Do an initial dispatch to load the products | |||
* Change the template to use the observable | |||
<syntaxhighlight lang="js"> | |||
// Create a variable to store the selector in | |||
products$: Observable<Product[]> | |||
ngOnInit(): void { | |||
... | |||
// Set the select and call initial dispatch | |||
this.products$ = this.store.select(getProducts) | |||
this.store.dispatch(ProductActions.loadProducts()) | |||
... | |||
} | |||
</syntaxhighlight> | |||
Within the template we can now use the observable. The Async pipe subscribes, gets the result and then unsubscribes and the component is destroyed | |||
'''Original Code''' | |||
<syntaxhighlight lang="html"> | |||
<div class="card-body" | |||
*ngIf="products?.length"> | |||
<div class="list-group"> | |||
<button class="list-group-item list-group-item-action rounded-0" | |||
*ngFor="let product of products" | |||
... | |||
</syntaxhighlight> | |||
'''New Code''' | |||
<syntaxhighlight lang="html"> | |||
<div class="card-body" | |||
*ngIf="products$ | async as products"> | |||
<div class="list-group"> | |||
<button class="list-group-item list-group-item-action rounded-0" | |||
*ngFor="let product of products" | |||
... | |||
</syntaxhighlight> | |||
===Error Handling=== | |||
We manage this we need to | |||
* Add Error to interface, initialize the state | |||
* Make a selector | |||
* Handle the action in the reducer to set the error | |||
* Amend the Effect to use error | |||
* Change the component to use the error | |||
====Add Error==== | |||
<syntaxhighlight lang="ts"> | |||
export interface ProductState { | |||
showProductCode: boolean; | |||
currentProduct: Product; | |||
products: Product[], | |||
error: String, | |||
} | |||
const initialState: ProductState = { | |||
showProductCode: true, | |||
currentProduct: null, | |||
products: [], | |||
error: '' | |||
} | |||
</syntaxhighlight> | |||
====Make Selector==== | |||
<syntaxhighlight lang="ts"> | |||
export const getError = createSelector( | |||
getProductFeatureState, | |||
state => state.error | |||
); | |||
</syntaxhighlight> | |||
====Handle the action==== | |||
Make sure you null the thing in the state the failure refers too. In our case products. | |||
<syntaxhighlight lang="ts"> | |||
on(ProductActions.loadProductsFailure, (state, action) : ProductState => { | |||
return { | |||
...state, | |||
products: [], | |||
error: action.error | |||
} | |||
}), | |||
</syntaxhighlight> | |||
====Change the component==== | |||
Add the error message to the component and the action | |||
<syntaxhighlight lang="ts"> | |||
// Declare an observable | |||
errorMessage$: Observable<string> | |||
ngOnInit(): void { | |||
.... | |||
// Set the select to monitor for changes. | |||
this.errorMessage$ = this.store.select(getError) | |||
... | |||
</syntaxhighlight> | |||
And the html template | |||
<syntaxhighlight lang="html"> | |||
<div *ngIf="errorMessage$ | async as errorMessage" | |||
class="alert alert-danger"> | |||
Error: {{ errorMessage }} | |||
</syntaxhighlight> | |||
====Amend the Effect==== | |||
We do this by adding a catch error after the call to the Success function. In our case loadProductSuccess. | |||
<syntaxhighlight lang="ts"> | |||
loadProducts$ = createEffect(() => { | |||
return this.actions$.pipe( | |||
ofType(ProductActions.loadProducts), | |||
mergeMap(action => | |||
this.productService.getProducts().pipe( | |||
map(products => ProductActions.loadProductsSuccess({products})), | |||
catchError(error => of(ProductActions.loadProductsFailure({error}))) | |||
)) | |||
) | |||
}); | |||
} | |||
</syntaxhighlight> | |||
'''Note''' the success and failure are placed in the pipe() after the call to the service. Lots of brackets might make this hard to read and the failure needs to be wrapped in an observable with of but basically | |||
<syntaxhighlight lang="ts"> | |||
doSomeThing.pipe( | |||
map(products => doActionSuccess({params})), | |||
catchError(error => of(doActionFailure({error}))) | |||
) | |||
</syntaxhighlight> | |||
=Change Interface= | |||
We are going to change the interface on the same to have a product id rather than a product | |||
<syntaxhighlight lang="ts"> | |||
// Change interface | |||
export interface ProductState { | |||
showProductCode: boolean; | |||
currentProductId: number | null; | |||
products: Product[], | |||
error: String, | |||
} | |||
// Rename to current product Id | |||
const initialState: ProductState = { | |||
showProductCode: true, | |||
currentProductId: null, | |||
products: [], | |||
error: '' | |||
} | |||
// Add new selector getCurrentProductId | |||
export const getCurrentProductId = createSelector( | |||
getProductFeatureState, | |||
state => state.currentProductId | |||
); | |||
// Amend the getCurrentProduct to | |||
// a) if current product id = 0, create empty product | |||
// b) if not 0 either find the product with the id or | |||
// c) return null | |||
export const getCurrentProduct = createSelector( | |||
getProductFeatureState, | |||
getCurrentProductId, | |||
(state, currentProductId) => { | |||
if(currentProductId === 0 ) { | |||
return { | |||
id: 0, | |||
productName: '', | |||
productCode: 'New', | |||
description: '', | |||
starRating: 0, | |||
} as Product | |||
} | |||
else { | |||
return currentProductId ? | |||
state.products.find(p=> p.id === currentProductId) : | |||
null | |||
} | |||
} | |||
); | |||
</syntaxhighlight> | |||
Change the action to use the product id | |||
<syntaxhighlight lang="ts"> | |||
export const setCurrentProduct = createAction( | |||
'[Product] Set current product', | |||
props<{currentProductId: number}>() | |||
) | |||
</syntaxhighlight> | |||
Now the action is an Id we need to change the usage of the action to use the product id | |||
<syntaxhighlight lang="ts"> | |||
productSelected(product: Product): void { | |||
this.store.dispatch( | |||
ProductActions.setCurrentProduct( | |||
{currentProductId : product.id})); | |||
} | |||
</syntaxhighlight> | |||
Finally we update the reducer functions to use the currentProductId rather than product | |||
<syntaxhighlight lang="ts"> | |||
on(ProductActions.setCurrentProduct, (state, action) : ProductState => { | |||
return { | |||
...state, | |||
currentProductId: action.currentProductId | |||
} | |||
}), | |||
on(ProductActions.clearCurrentProduct, (state) : ProductState => { | |||
return { | |||
...state, | |||
currentProductId: null | |||
} | |||
}), | |||
on(ProductActions.initCurrentProduct, (state) : ProductState => { | |||
return { | |||
...state, | |||
currentProductId: 0 | |||
} | |||
}), | |||
</syntaxhighlight> | |||
=Doing Update= | |||
==Introduction== | |||
This is a second walk through of the process. Hopefully this will show any gaps in the current documentation. For the update we need to do the same as the load products | |||
==Define Actions== | |||
We need three new action update, success and failure. | |||
<syntaxhighlight lang="ts"> | |||
export const updateProduct = createAction( | |||
'[Product] Update Product', | |||
props<{product: Product}>() | |||
) | |||
export const updateProductSuccess = createAction( | |||
'[Product] Update Product Success', | |||
props<{product: Product}>() | |||
) | |||
export const updateProductFailure = createAction( | |||
'[Product] Update Product Failure', | |||
props<{error: string}>() | |||
) | |||
</syntaxhighlight> | |||
==Dispatching Action== | |||
'''Template-driven''' forms are not used with ngRx because they update immediately. We are expected to use '''Reactive Forms''' | |||
In the form we need to | |||
* import the actions, | |||
* inject the store, | |||
* call the dispatch | |||
<syntaxhighlight lang="ts"> | |||
... | |||
import * as ProductActions from './../state/product.actions' | |||
... | |||
constructor( | |||
private fb: FormBuilder, | |||
private productService: ProductService, | |||
private store: Store<State>) { | |||
... | |||
saveProduct(originalProduct: Product): void { | |||
if (this.productForm.valid) { | |||
if (this.productForm.dirty) { | |||
// Copy over all of the original product properties | |||
// Then copy over the values from the form | |||
// This ensures values not on the form, such as the Id, are retained | |||
const product = { ...originalProduct, ...this.productForm.value }; | |||
if (product.id === 0) { | |||
... | |||
} else { | |||
this.store.dispatch(ProductActions.updateProduct({product})) | |||
} | |||
</syntaxhighlight> | |||
==Build Update Effect== | |||
To create this we use concatMap rather than mergeMap | |||
<syntaxhighlight lang="ts"> | |||
updateProduct$ = createEffect(() => { | |||
return this.actions$.pipe( | |||
ofType(ProductActions.updateProduct), | |||
concatMap(action => | |||
this.productService.updateProduct(action.product).pipe( | |||
map(product => ProductActions.updateProductSuccess({product})), | |||
catchError(error => of(ProductActions.updateProductFailure({error}))) | |||
)) | |||
) | |||
}); | |||
</syntaxhighlight> | |||
==Create Reducer Functions for Actions== | |||
We need a success and failure. For success we need to create a copy of the array but replacing the change item with the one provided in the action. We also need to change the currentProductId to be the id of the action product id. | |||
<syntaxhighlight lang="ts"> | |||
on(ProductActions.updateProductSuccess, (state, action) : ProductState => { | |||
const updatedProducts = state.products.map( | |||
item => action.product.id === item.id ? action.product : item) | |||
return { | |||
...state, | |||
products: updatedProducts, | |||
currentProductId: action.product.id, | |||
error: ''} | |||
}), | |||
on(ProductActions.updateProductFailure, (state, action) : ProductState => { | |||
return { | |||
...state, | |||
error: action.error | |||
} | |||
}), | |||
</syntaxhighlight> | |||
'''Make sure you use a immutable method when dealing with state ie not for loops''' | |||
=Architecture Considerations= | |||
==Presentation and Container== | |||
==On Push== | |||
This seems to be pretty important the course mentioned without OnPush the components are all checked for changes from the root component downwards unless onPush is specified. If you do specify OnPush on a component it will only be updated if | |||
*A new input reference is passed or | |||
*A DOM Event handler of the component is triggered | |||
*You Use an async pipe in the components template or it's children | |||
==Barrel Files (index.ts)== | |||
These are files which are usually named index.ts. They contain enough information for other parts of the app or the system to consume our functionality. In the original workflow we created files which include more the one concerned for convenience. In this section we will correct this. | |||
===Reducer=== | |||
We separate what people need to consume and what is internal to the reducer. | |||
<br> | |||
The Index.ts contains | |||
* Extended App State | |||
* Feature Selector | |||
* Selectors | |||
<br> | |||
The Reducer contains | |||
* The module state | |||
* Initial module state | |||
* Create Reducer functions | |||
===Actions=== | |||
Some actions are used on the pages and some are used to call APIs. We separate this to make it clear. | |||
<br> | |||
Index.ts contains | |||
* References to the api and page actions e.g. | |||
<syntaxhighlight lang="ts"> | |||
import * as ProductPageActions from './product-page-actions' | |||
import * as ProductAPIActions from './product-api.actions' | |||
export {ProductAPIActions, ProductPageActions} | |||
</syntaxhighlight> | |||
product-api.ts | |||
* All of the Outbound API Actions, e.g. | |||
** loadProductsSuccess | |||
** loadProductsFailure | |||
<br> | |||
product-page.ts | |||
* All of the Page Actions | |||
** toggleProductCode | |||
** setCurrentProduct | |||
** loadProducts | |||
==Breaking Down State== | |||
Three approaches are provided below for breaking out state from the module. | |||
<br> | |||
[[File:Angular modules state.png|800px]] |
Latest revision as of 08:27, 6 September 2020
Introduction
ngRx is a version of redux built for Angular. It provides,
- Centralize immutable state
- Performance
- Testability due to use of pure functions
- Tooling, advance logging, visualize state tree
Redux Pattern
It provides a “unidirectional data flow” that helps to manage and organise data better and makes debugging a lot easier.
- the UI dispatches an action to the reducer
- the reducer sends the new state to the store
- the store, sets the state in the reducer, and notifies the selector
- the selector sends out new state event to all subscribed observers
- Store, single source truth for state,
- Don not sotr unshared status, angular state
- Non serailizable state
- Actions, Dispatch action to change state
- Reducers, used to change state state, examples
- set user details property on login,
- toggle table a side menu visible
- set global spinner visible property
When to Use ngRx
- provides a place for UI state to retain ti between router views
- provides client-side cache to use as needed
- reducer updates the store and the store notifies all subscribers
- it has great tooling
Sample Application Architecture
Captured this be is kinda gives an approach for small apps and where to start. Somethings it is the size of the task which can distract people from starting it.
Walkthrough a Change
Introduction
Redux follows a pattern regardless of the problem you are trying to solve. This may look like the React Redux Page after I have finished.
Process
We are going to update the display product code state.
- User issues a click
- The component dispatches an action to the reducer
- The reducer uses the action as input and the current state from the store to create new state
- The reducer sends the state to the store
- The store broadcasts the state to all subscribers, in this case the component
- Component updates it's bound property
Selector
In the example a new item for Redux was created compared to the React version. This was a selector. I guess it is a product differentiater. The benefits were
- Provide a strongly type API
- Decouple the store from the components
- Encapsulate complex data transformations
- Reusable
- Memoized (cache)
Installing Packages
Initializing the Store
Installing
Using the angular cli app.modules.ts is updated along with packages.json
ng add @ngrx/store
This makes the following changes
import { StoreModule } from '@ngrx/store';
...
@NgModule({
imports: [
BrowserModule,
...
StoreModule.forRoot({}, {})
],
Module Considerations
For modules, like the router module, there is a forFeature equivalent of the StoreModule. We can, just like react have multiple reducers for a feature too. Each reducer is stored using tag/value.
StoreModule.forFeature('products', {
'productList': listReducer,
'productData': dataReducer
})
Installing DevTools
First install the tools
ng add @ngrx/store-devtools
Initialize in the app module with
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
...
@NgModule({
imports: [
BrowserModule,
...
StoreDevtoolsModule.instrument({
name: 'Application Name to Recognise'
maxAge: 25,
logOnly: environment.production
})
],
Defining State
Module State
To define state, we have typescript so we can be a bit better by using the interfaces not available in JS at the time In the module specific state we have
export interface ProductState {
showProductCode: boolean;
currentProduct: Product;
products: Product[]
}
App State
For the app we might have wanted to do the following
export interface State {
products: ProductState;
user: any
...
}
But this breaks the Lazy Loading as the module code is lazy loaded. And omitting the products from the App State but leaving the users as this is not lazy loaded so it is recommended we omit the states in the app state and extend the module state to include the app state.
export interface State {
user: any
}
Extending the Module State
Because of Lazy Loading we cannot specify all of the state for the app in the App state. In the module we may need the state of other components so to work around this we extend the state in the module to include the app state.
import * as AppState from '../../state/app.state';
export interface State extends AppState.State {
products: ProductState
}
Initializing the Module State
It is good practice and deterministic to initialise the state. We can do this be defining a const for use with the reducer creation
const initialState: ProductState = {
showProductCode: true,
currentProduct: null,
products: []
}
Defining Actions
Basic
Just make a const to represent the action.
import { createAction } from '@ngrx/store'
export const toggleProductCode = createAction('[Product] Toggle product code')
export const setCurrentProduct = createAction('[Product] Set current product')
export const clearCurrentProduct = createAction('[Product] Clear current product')
export const initCurrentProduct = createAction('[Product] Init current product')
Properties
Some actions will need properties to work. In the above example this would be setCurrentProduct so we need to add it as a props to pass. In Angular we do this.
import { createAction, props } from '@ngrx/store'
import { Product } from '../product'
...
export const setCurrentProduct = createAction(
'[Product] Set current product',
props<{product: Product}>()
)
Complex Actions
Some actions need to be broken down in smaller actions. The point to breaking them down is probably for debugging so we can see where it went wrong. For example
- Outgoing
- dispatch load something
- Incoming
- dispatch got something
- dispatch got error
Building a Reducer
Reducer with no Parameters
The function return a copy of the state not a modified state by ... (spreading) the original state and adding or replacing the new state.
import { createReducer, on, createAction } from '@ngrx/store';
import * as ProductActions from './product.actions'
export const productReducer = createReducer<ProductState>(
initialState,
on(ProductActions.toggleProductCode, (state) : ProductState => {
return {
...state,
showProductCode: !state.showProductCode
}
})
)
Do not forget to update the module to have the reducer assigned.
...
StoreModule.forFeature('products', productReducer)
...
Reducer with Parameters
To use a reducer which has parameters we simply deconstruct the second parameter on the on parameter.
on(ProductActions.setCurrentProduct, (state, params) : ProductState => {
return {
...state,
currentProduct: params.product
}
})
Creating A Selector
Basic
The selector is way to product an enum to get a specific piece of data from the State. Each state section you want you define a selector and and accessor.
const getProductFeatureState = createFeatureSelector<ProductState>('products')
export const getShowProductCode = createSelector(
getProductFeatureState,
state => state.showProductCode
);
Transformation In Selector
So to get the current product we need to get the id and then the relevant product. This was achieved with the following code.
export const getCurrentProductId = createSelector(
getProductFeatureState,
state => state.currentProductId
);
export const getCurrentProduct = createSelector(
getProductFeatureState,
getCurrentProductId,
(state, currentProductId) =>
state.products.find(p => p.id === currentProductId)
);
Dispatching an Action
We need to inject the store into the component and dispatch the action. We do this using dependency injection and for the store and the Angular checkChanged to to initiate the dispatch. Note We are using the product definition of State and not the App definition as it will lack the product reducer definition.
...
import { State } from '../state/product.reducer';
import * as ProductActions from './../state/product.actions'
...
constructor(
private productService: ProductService,
private store: Store<State>) { }
..
checkChanged(): void {
this.store.dispatch(ProductActions.toggleProductCode())
}
Subscribing to the Store
We subscribe to the store using the name we used in the module. This can be done in the ngOnInit function for Angular. (This is an example of what using a selector it is trying to solve)
Without selectors we would have
this.store.select('products').subscribe(
products = this.displayCode = products.showProductCode
)
With Selectors we have
import {State, getShowProductCode } from '../state/product.reducer'
...
this.store.select(getShowProductCode).subscribe(
showProductCode => this.displayCode = showProductCode
)
NgRx Effects Library
Some definitions,
- pure functions are function which return the same result given the same input
- side effects are an operation which depends on or operates with an external source such as external state, devices, or an API
Our goal is to find where to put things with side effects
- Components should be as pure possible
- Reducers are pure functions
So we create an effects, effects
- take an action,
- do some work and,
- dispatch a new action
So the process for load products is
- Component issues a dispatch action loadProducts
- Action calls the reducer
- Reducer cannot do async requests to calls the effect
- Effect talks to the service and gets and response
- Service passes the response to the effect
- The Effect send the loadProductSuccess to the reducer
- Reducer updates the Store
- The Selector listening on the store gets the changes
Creating an Effects
Note ending a variable with $ (dollar) suffix is a convention in the community to denote an observable.
Installing
ng add @ngrx/effects
This amends the app.module.ts as follows
...
import { EffectsModule } from '@ngrx/effects';
@NgModule({
imports: [
BrowserModule,
...
EffectsModule.forRoot([])
Define a service
This takes an action and and your service
@Injectable()
export class ProductEffects {
constructor(
private actions$:Actions,
private productService: ProductService) {}
}
Create an Effect
We create an effect in our product module call product.effects.ts
loadProducts$ = createEffect() => {
return this.actions$.pipe(
...
}
Filter the Actions
We listen for the appropriate action by using the ofType filter, in our case ProductActions.loadProducts
...
ofType(ProductActions.loadProducts),
...
}
Map and call the Service
...
mergeMap(action =>
this.productService.getProducts().pipe(
...
}
Return the new action
Return the new action with data, in our case success
...
map(products =>
ProductActions.loadProductSuccess({products})))
...
}
Final Statement
import { ofType, createEffect, Actions } from '@ngrx/effects'
...
loadProducts$ = createEffect() => {
return this.actions$.pipe(
ofType(ProductActions.loadProducts),
mergeMap(action =>
this.productService.getProducts().pipe(
map(products =>
ProductActions.loadProductSuccess({products})))
)
)
});
}
Create Reducer Function
Create the loadProductsSuccess Reducer Function
on(ProductActions.loadProductsSuccess, (state, action) : ProductState => {
return {
...state,
products: action.products
}
}),
About RxJs Operators in Effects
- switchMap Cancels the current subscription/request and can cause race conditions
- Use for get requests or cancelable requests like searches (maybe read-only?)
It cancels the current subscription if a new value is omitted. This means if someone dispatches a second save operation before the first actions HTTP request returns to the effect, the first request will be cancelled and save might not happen
- concatMap Runs subscriptions/requests in order and is less performant
- Use for get,post and put requests when order is important
concatMap Runs subscriptions/requests in order and will wait for the last request to finish before starting the next.
- mergeMap Runs subscription/requests in parallel
- Use for get,put,post and delete methods when order is not important
mergeMap Runs subscription/requests in parallel, it is faster than concatMap but does not guarantee order
- exhaustMap Ignores all subsequent subscriptions/requests until it completes
- Use for login when you do not want more requests until the initial one is complete
exhaustMap Ignores all subsequent subscriptions/requests dispatched until the current request is complete. Use this when you do not want to queue up any more requests until the initial one is complete
Add Our Effect to Product Module
Ezzy pezzy lemon squezzy
import { ProductEffects } from './state/products.effects';
import { EffectsModule } from '@ngrx/effects';
import { NgModule } from '@angular/core';
...
@NgModule({
imports: [
SharedModule,
RouterModule.forChild(productRoutes),
StoreModule.forFeature('products', productReducer),
EffectsModule.forFeature([ProductEffects])
],
...
Using the Effect in the Component
We need to
- Create an observer to watch for product changes
- Do an initial dispatch to load the products
- Change the template to use the observable
// Create a variable to store the selector in
products$: Observable<Product[]>
ngOnInit(): void {
...
// Set the select and call initial dispatch
this.products$ = this.store.select(getProducts)
this.store.dispatch(ProductActions.loadProducts())
...
}
Within the template we can now use the observable. The Async pipe subscribes, gets the result and then unsubscribes and the component is destroyed Original Code
<div class="card-body"
*ngIf="products?.length">
<div class="list-group">
<button class="list-group-item list-group-item-action rounded-0"
*ngFor="let product of products"
...
New Code
<div class="card-body"
*ngIf="products$ | async as products">
<div class="list-group">
<button class="list-group-item list-group-item-action rounded-0"
*ngFor="let product of products"
...
Error Handling
We manage this we need to
- Add Error to interface, initialize the state
- Make a selector
- Handle the action in the reducer to set the error
- Amend the Effect to use error
- Change the component to use the error
Add Error
export interface ProductState {
showProductCode: boolean;
currentProduct: Product;
products: Product[],
error: String,
}
const initialState: ProductState = {
showProductCode: true,
currentProduct: null,
products: [],
error: ''
}
Make Selector
export const getError = createSelector(
getProductFeatureState,
state => state.error
);
Handle the action
Make sure you null the thing in the state the failure refers too. In our case products.
on(ProductActions.loadProductsFailure, (state, action) : ProductState => {
return {
...state,
products: [],
error: action.error
}
}),
Change the component
Add the error message to the component and the action
// Declare an observable
errorMessage$: Observable<string>
ngOnInit(): void {
....
// Set the select to monitor for changes.
this.errorMessage$ = this.store.select(getError)
...
And the html template
<div *ngIf="errorMessage$ | async as errorMessage"
class="alert alert-danger">
Error: {{ errorMessage }}
Amend the Effect
We do this by adding a catch error after the call to the Success function. In our case loadProductSuccess.
loadProducts$ = createEffect(() => {
return this.actions$.pipe(
ofType(ProductActions.loadProducts),
mergeMap(action =>
this.productService.getProducts().pipe(
map(products => ProductActions.loadProductsSuccess({products})),
catchError(error => of(ProductActions.loadProductsFailure({error})))
))
)
});
}
Note the success and failure are placed in the pipe() after the call to the service. Lots of brackets might make this hard to read and the failure needs to be wrapped in an observable with of but basically
doSomeThing.pipe(
map(products => doActionSuccess({params})),
catchError(error => of(doActionFailure({error})))
)
Change Interface
We are going to change the interface on the same to have a product id rather than a product
// Change interface
export interface ProductState {
showProductCode: boolean;
currentProductId: number | null;
products: Product[],
error: String,
}
// Rename to current product Id
const initialState: ProductState = {
showProductCode: true,
currentProductId: null,
products: [],
error: ''
}
// Add new selector getCurrentProductId
export const getCurrentProductId = createSelector(
getProductFeatureState,
state => state.currentProductId
);
// Amend the getCurrentProduct to
// a) if current product id = 0, create empty product
// b) if not 0 either find the product with the id or
// c) return null
export const getCurrentProduct = createSelector(
getProductFeatureState,
getCurrentProductId,
(state, currentProductId) => {
if(currentProductId === 0 ) {
return {
id: 0,
productName: '',
productCode: 'New',
description: '',
starRating: 0,
} as Product
}
else {
return currentProductId ?
state.products.find(p=> p.id === currentProductId) :
null
}
}
);
Change the action to use the product id
export const setCurrentProduct = createAction(
'[Product] Set current product',
props<{currentProductId: number}>()
)
Now the action is an Id we need to change the usage of the action to use the product id
productSelected(product: Product): void {
this.store.dispatch(
ProductActions.setCurrentProduct(
{currentProductId : product.id}));
}
Finally we update the reducer functions to use the currentProductId rather than product
on(ProductActions.setCurrentProduct, (state, action) : ProductState => {
return {
...state,
currentProductId: action.currentProductId
}
}),
on(ProductActions.clearCurrentProduct, (state) : ProductState => {
return {
...state,
currentProductId: null
}
}),
on(ProductActions.initCurrentProduct, (state) : ProductState => {
return {
...state,
currentProductId: 0
}
}),
Doing Update
Introduction
This is a second walk through of the process. Hopefully this will show any gaps in the current documentation. For the update we need to do the same as the load products
Define Actions
We need three new action update, success and failure.
export const updateProduct = createAction(
'[Product] Update Product',
props<{product: Product}>()
)
export const updateProductSuccess = createAction(
'[Product] Update Product Success',
props<{product: Product}>()
)
export const updateProductFailure = createAction(
'[Product] Update Product Failure',
props<{error: string}>()
)
Dispatching Action
Template-driven forms are not used with ngRx because they update immediately. We are expected to use Reactive Forms In the form we need to
- import the actions,
- inject the store,
- call the dispatch
...
import * as ProductActions from './../state/product.actions'
...
constructor(
private fb: FormBuilder,
private productService: ProductService,
private store: Store<State>) {
...
saveProduct(originalProduct: Product): void {
if (this.productForm.valid) {
if (this.productForm.dirty) {
// Copy over all of the original product properties
// Then copy over the values from the form
// This ensures values not on the form, such as the Id, are retained
const product = { ...originalProduct, ...this.productForm.value };
if (product.id === 0) {
...
} else {
this.store.dispatch(ProductActions.updateProduct({product}))
}
Build Update Effect
To create this we use concatMap rather than mergeMap
updateProduct$ = createEffect(() => {
return this.actions$.pipe(
ofType(ProductActions.updateProduct),
concatMap(action =>
this.productService.updateProduct(action.product).pipe(
map(product => ProductActions.updateProductSuccess({product})),
catchError(error => of(ProductActions.updateProductFailure({error})))
))
)
});
Create Reducer Functions for Actions
We need a success and failure. For success we need to create a copy of the array but replacing the change item with the one provided in the action. We also need to change the currentProductId to be the id of the action product id.
on(ProductActions.updateProductSuccess, (state, action) : ProductState => {
const updatedProducts = state.products.map(
item => action.product.id === item.id ? action.product : item)
return {
...state,
products: updatedProducts,
currentProductId: action.product.id,
error: ''}
}),
on(ProductActions.updateProductFailure, (state, action) : ProductState => {
return {
...state,
error: action.error
}
}),
Make sure you use a immutable method when dealing with state ie not for loops
Architecture Considerations
Presentation and Container
On Push
This seems to be pretty important the course mentioned without OnPush the components are all checked for changes from the root component downwards unless onPush is specified. If you do specify OnPush on a component it will only be updated if
- A new input reference is passed or
- A DOM Event handler of the component is triggered
- You Use an async pipe in the components template or it's children
Barrel Files (index.ts)
These are files which are usually named index.ts. They contain enough information for other parts of the app or the system to consume our functionality. In the original workflow we created files which include more the one concerned for convenience. In this section we will correct this.
Reducer
We separate what people need to consume and what is internal to the reducer.
The Index.ts contains
- Extended App State
- Feature Selector
- Selectors
The Reducer contains
- The module state
- Initial module state
- Create Reducer functions
Actions
Some actions are used on the pages and some are used to call APIs. We separate this to make it clear.
Index.ts contains
- References to the api and page actions e.g.
import * as ProductPageActions from './product-page-actions'
import * as ProductAPIActions from './product-api.actions'
export {ProductAPIActions, ProductPageActions}
product-api.ts
- All of the Outbound API Actions, e.g.
- loadProductsSuccess
- loadProductsFailure
product-page.ts
- All of the Page Actions
- toggleProductCode
- setCurrentProduct
- loadProducts
Breaking Down State
Three approaches are provided below for breaking out state from the module.