Why NGRX Store?
In some cases, we need to manage the states inside a component, or even a global state to let our page render corresponding contents for users. NGRX uses store
to save a current state, and component can get this live state via observeable variables(from Rxjs
. In this way, the store isolate the state from the script of component. The NGRX doc is here.
Traditionally, we can use a variable as a member inside our component script and make this as a state flag, our code will review this variable and change their state during life cycle. But, to use NGRX can bring such advantages in my opinion:
- You put state and state-changer, state-handler in an isolated place.
- You will only have a interface to get/set state.
- Easier to pass your state, and isolate effect from your pure functions.
To make our code more elegant and replaceable, we can use facade pattern based on a module.facade.ts
, which will provide easy interfaces for components' script to control our store.
@ngrx/Store
A store is a 'holder' for current state, in our pattern we can use DI to get this store by us DI, and implement store in app.facade.ts
.
In our cases, our facade class will interact with selector and action, compared with the official image in follow.
Case study
Suppose we have a component showing a counter's value, it can call facade for 4 operations:
- Get counter's value continuously from facade
- Tell facade to increase counter
- Tell facade to decrease counter
- Tell facade to reset counter
Step 1: Action and state format
The first step is to define actions, which is the type of message our facade will dispatch, and the same message that will be caught by reducer and effect.
//module.actions.ts
import { createAction } from '@ngrx/store'
export const increment = createAction('[Counter Component] Increment')
export const decrement = createAction('[Counter Component] Decrement')
export const reset = createAction('[Counter Component] Reset')
export const error = createAction('[Counter Component] Fail', props<{ error: any }>())
Here we export 3 types of action, via given method createAction
from ngrx, we can add a string as the comment as a attribution in this method.
Also we declare the format of state as AppState
:
//component-state.model.ts
export interface AppState {
counter?: number
}
Step 2: Reducer within Store
Notice that the action is just the ‘Message Carrier’, and reducer plays the role maintains the states:
//module.reducer.ts
import { createReducer, on } from '@ngrx/store'
import { increment, decrement, reset } from './counter.actions'
export const featureKey = 'somemodule'
export const initialState: AppState = {
counter: 0,
} //Notice: reducer declare the initial state here
const _counterReducer = createReducer(
initialState,
on(increment, (state) => ({
...state,
counter: state.counter + 1,
})),
on(decrement, (state) => ({
...state,
counter: state.counter - 1,
})),
on(reset, (state) => ({
...state,
counter: 0,
}))
) //all action handler should be in `createReducer`'s parameters.
export function counterReducer(state, action) {
return _counterReducer(state, action)
}
Step 3: Selector
Now the state was save in to store. here we only have one : initialState.counter = 0
//module.selector.ts
import { createSelector } from '@ngrx/store'
export const getState = createFeatureSelector<AppState>(featureKey)
const getCount = createSelector(getState, (state: AppState) => state.counter)
export const query = {
getCount,
}
Component with Store
Imagine that the component script is the code behind our visual page, and the store is just like the code behind the code bind, and the facade works as the only interface. As for component, we have its html file, css file, and the component script, while as for the store, we need create these script file : action , reducer, effect, and selector:
Action: define type of action.
Reducer: Capture action dispatched, and replace store’s state. Also, it will init a default state for state.
Selector: Provided a pipe from store, and provided public query method to let other class get sync state.
Effect: Capture action dispatched, and do other things, like call api to send http request, or dispatch other action.
Set State
First let us review what state we have:
//component-state.model.ts
export interface AppState {
counter?: number
}
To set a state, component script need to call facade’s method, and the latter will dispatch a action:
//component.facade.ts
import { Actions } from './module.actions'
public increaseCounter():void {
this.store.dispatch(Actions.increment());
}
And reducer will capture it and change store’s state :
...
on(increment, state => ({
...state,
counter: state.counter + 1
})),
...
Get State from facade
Get observable variable from state via selector:
//component.facade.ts
public counter$ = this.store.pipe(select(query.getCount));
From component's aspect
//module.component.ts
public counter$ : Observable<number>;
public ngOnInit():void {
this.counter$ = this.facade.counter$;
this.facade.increaseCounter();
}
Normally, we assign var as xxx+$ to mark it as an Observable Source
. To capture it's inner source, there's two ways to do that:
- Declare an observable source in container component, and pass it into child component using html template : For example, if we have a
counter-component.bs-container.ts
, and we declare apublic counter$ = Observable<number>;
, and bind it usingthis.counter$ = this.facade.counter$;
, we can use it in container's template:
//counter-component.bs-container.ts
@Component({
selector: 'counter-component-bs-container',
template: `
<counter-component
[counter]="counter$ | async"
>
</counter-component>
`
})
And we can directly use counter
in counter-component.
//counter-component.component.ts
@Input() counter: number;
- Subscribe this source in the same place.
//module.component.ts
public counter$ : Observable<number>;
public counter : number;
public ngOnInit():void {
this.counter$ = this.facade.counter$;
//bind
this.counter$.subscribe(i=>this.counter=i);
this.facade.increaseCounter();
}
Deep in Store : RxJS
In our procedure, we are actually using async observable variables to link all the ‘observable chain’.
A observable can have a subscribe
method, to allow you to subscribe it.
this.counter$.subscribe((i) => (this.counter = i))
Also, it have next
to allow you to update it.
const observable = new Observable((subscriber) => {
subscriber.next(1)
subscriber.next(2)
subscriber.next(3)
setTimeout(() => {
subscriber.next(4)
subscriber.complete()
}, 1000)
})
@ngrx/Effect
Effect allow you to do extra codes. As we know, reducer will only update states and send actions. But effect can give ability to run any code when receiving an action. For example, if we send a action increment
, we can also write an effect to let it to call an API.
import { Injectable } from '@angular/core'
import { Actions, createEffect, ofType } from '@ngrx/effects'
import * as CounterActions from './module.actions'
export class ProfileEffects {
constructor(
private actions$: Actions,
//your api service
private api: ApiService
) {}
incrementEffectAPI$ = createEffect(() =>
this.actions$.pipe(
ofType(CounterActions.increment),
switchMap((action) => {
const apiResult$ = this.api.callSomeAPI()
return apiResult$.pipe(
//if success, trigger another increment.
tap((res) => CounterActions.increment),
//if fail, send an error action
catchError((error) => of(ProfileActions.error({ error })))
)
})
)
)
}
@ngrx: Unit Test
We mainly test reducer
and selector
.
Test reducer
import * as CounterActions from './module.actions'
import * as fromCounter from './module.reducer'
describe('Profile Reducer', () => {
it('should ignore undefined action', () => {
// arrange
const { initialState } = fromCounter
const action = createAction('undefined')
// act
const state = fromCounter.profileReducer(initialState, action)
// assert
expect(state).toEqual(initialState)
})
it('should increase', () => {
// arrange
const { initialState } = fromCounter
const action = CounterActions.increase()
// act
const state = fromCounter.profileReducer(initialState, action)
// assert
expect(state.counter).toBe(1)
})
})
Test Selector
import { query } from './module.selectors'
import { AppState } from '/component-state.model.ts'
describe('Counter Selectors', () => {
it('should select counter value', () => {
// arrange
const counter = 7
const state: Partal<AppState> = {
counter,
}
// act
const selectorResult = query.getCount.projector(state)
// assert
expect(selectorResult).toBe(counter)
})
})