Angular ngrx

From bibbleWiki
Jump to navigation Jump to search

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

Redux pattern2.png

  • 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. Angular sample app architecture.png

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. Redux example.png

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

Ngrx effects.png 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.
Angular modules state.png