The topic is too wide. It will be like a tutorial. I will give it a try anyway. In a normal case, you will have an action, reducer and a store. Actions are dispatched by the store, which is subscribed to by the reducer. Then the reducer acts on the action, and forms a new state. In examples, all states are at the frontend, but in a real app, it needs to call backend DB or MQ, etc, these calls have side effects. The framework used to factor out these effects into a common place.
Let's say you save a Person Record to your database, action: Action = {type: SAVE_PERSON, payload: person}
. Normally your component won't directly call this.store.dispatch( {type: SAVE_PERSON, payload: person} )
to have the reducer call the HTTP service, instead it will call this.personService.save(person).subscribe( res => this.store.dispatch({type: SAVE_PERSON_OK, payload: res.json}) )
. The component logic will get more complicated when adding real life error handling. To avoid this, it will be nice to just call
this.store.dispatch( {type: SAVE_PERSON, payload: person} )
from your component.
That is what the effects library is for. It acts like a JEE servlet filter in front of reducer. It matches the ACTION type (filter can match urls in java world) and then acts on it, and finally returns a different action, or no action, or multiple actions. Then the reducer responds to the output actions of effects.
To continue the previous example, with the effects library:
@Effects() savePerson$ = this.stateUpdates$.whenAction(SAVE_PERSON)
.map<Person>(toPayload)
.switchMap( person => this.personService.save(person) )
.map( res => {type: SAVE_PERSON_OK, payload: res.json} )
.catch( e => {type: SAVE_PERSON_ERR, payload: err} )
The weave logic is centralised into all Effects and Reducers classes. It can easily grow more complicated, and at the same time this design makes other parts much simpler and more re-usable.
For example if the UI has auto saving plus manually saving, to avoid unnecessary saves, UI auto save part can just be triggered by timer and manual part can be triggered by user click. Both would dispatch a SAVE_CLIENT action. The effects interceptor can be:
@Effects() savePerson$ = this.stateUpdates$.whenAction(SAVE_PERSON)
.debounce(300).map<Person>(toPayload)
.distinctUntilChanged(...)
.switchMap( see above )
// at least 300 milliseconds and changed to make a save, otherwise no save
The call
...switchMap( person => this.personService.save(person) )
.map( res => {type: SAVE_PERSON_OK, payload: res.json} )
.catch( e => Observable.of( {type: SAVE_PERSON_ERR, payload: err}) )
only works once if there is an error. The stream is dead after an error is thrown because the catch tries on outer stream. The call should be
...switchMap( person => this.personService.save(person)
.map( res => {type: SAVE_PERSON_OK, payload: res.json} )
.catch( e => Observable.of( {type: SAVE_PERSON_ERR, payload: err}) ) )
Or another way: change all ServiceClass services methods to return ServiceResponse which contains error code, error message and wrapped response object from server side, i.e.
export class ServiceResult {
error: string;
data: any;
hasError(): boolean {
return error != undefined && error != null; }
static ok(data: any): ServiceResult {
let ret = new ServiceResult();
ret.data = data;
return ret;
}
static err(info: any): ServiceResult {
let ret = new ServiceResult();
ret.error = JSON.stringify(info);
return ret;
}
}
@Injectable()
export class PersonService {
constructor(private http: Http) {}
savePerson(p: Person): Observable<ServiceResult> {
return http.post(url, JSON.stringify(p)).map(ServiceResult.ok);
.catch( ServiceResult.err );
}
}
@Injectable()
export class PersonEffects {
constructor(
private update$: StateUpdates<AppState>,
private personActions: PersonActions,
private svc: PersonService
){
}
@Effects() savePerson$ = this.stateUpdates$.whenAction(PersonActions.SAVE_PERSON)
.map<Person>(toPayload)
.switchMap( person => this.personService.save(person) )
.map( res => {
if (res.hasError()) {
return personActions.saveErrAction(res.error);
} else {
return personActions.saveOkAction(res.data);
}
});
@Injectable()
export class PersonActions {
static SAVE_OK_ACTION = "Save OK";
saveOkAction(p: Person): Action {
return {type: PersonActions.SAVE_OK_ACTION,
payload: p};
}
... ...
}
One correction to my previous comment: Effect-Class and Reducer-Class, if you have both Effect-class and Reducer-class react to the same action type, Reducer-class will react first, and then Effect-class. Here is an example:
One component has a button, once clicked, called: this.store.dispatch(this.clientActions.effectChain(1));
which will be handled by effectChainReducer
, and then ClientEffects.chainEffects$
, which increases the payload from 1 to 2; wait for 500 ms to emit another action: this.clientActions.effectChain(2)
, after handled by effectChainReducer
with payload=2 and then ClientEffects.chainEffects$
, which increases to 3 from 2, emit this.clientActions.effectChain(3)
, ..., until it is greater than 10, ClientEffects.chainEffects$
emits this.clientActions.endEffectChain()
, which changes the store state to 1000 via effectChainReducer
, finally stops here.
export interface AppState {
... ...
chainLevel: number;
}
// In NgModule decorator
@NgModule({
imports: [...,
StoreModule.provideStore({
... ...
chainLevel: effectChainReducer
}, ...],
...
providers: [... runEffects(ClientEffects) ],
...
})
export class AppModule {}
export class ClientActions {
... ...
static EFFECT_CHAIN = "Chain Effect";
effectChain(idx: number): Action {
return {
type: ClientActions.EFFECT_CHAIN,
payload: idx
};
}
static END_EFFECT_CHAIN = "End Chain Effect";
endEffectChain(): Action {
return {
type: ClientActions.END_EFFECT_CHAIN,
};
}
static RESET_EFFECT_CHAIN = "Reset Chain Effect";
resetEffectChain(idx: number = 0): Action {
return {
type: ClientActions.RESET_EFFECT_CHAIN,
payload: idx
};
}
export class ClientEffects {
... ...
@Effect()
chainEffects$ = this.update$.whenAction(ClientActions.EFFECT_CHAIN)
.map<number>(toPayload)
.map(l => {
console.log(`effect chain are at level: ${l}`)
return l + 1;
})
.delay(500)
.map(l => {
if (l > 10) {
return this.clientActions.endEffectChain();
} else {
return this.clientActions.effectChain(l);
}
});
}
// client-reducer.ts file
export const effectChainReducer = (state: any = 0, {type, payload}) => {
switch (type) {
case ClientActions.EFFECT_CHAIN:
console.log("reducer chain are at level: " + payload);
return payload;
case ClientActions.RESET_EFFECT_CHAIN:
console.log("reset chain level to: " + payload);
return payload;
case ClientActions.END_EFFECT_CHAIN:
return 1000;
default:
return state;
}
}
If you run the above code, the output should look like:
client-reducer.ts:51 reducer chain are at level: 1
client-effects.ts:72 effect chain are at level: 1
client-reducer.ts:51 reducer chain are at level: 2
client-effects.ts:72 effect chain are at level: 2
client-reducer.ts:51 reducer chain are at level: 3
client-effects.ts:72 effect chain are at level: 3
... ...
client-reducer.ts:51 reducer chain are at level: 10
client-effects.ts:72 effect chain are at level: 10
It indicates reducer runs first before effects, Effect-Class is a post-interceptor, not pre-interceptor. See flow diagram: