⬆️ ⬇️

Stop using Ngrx / effects for this

brute force



Sometimes the simplest implementation of functionality ultimately creates more problems than good, only by increasing the complexity elsewhere. The end result is a buggy architecture that no one wants to touch.



Translator's notes

The article was written in 2017, but is relevant to this day. Aimed at people experienced in RxJS and Ngrx, or who want to try Redux in Angular.



The code snippets have been updated based on the current RxJS syntax and are slightly modified to improve readability and ease of understanding.



Ngrx / store is an Angular library that helps contain the complexity of individual functions. One of the reasons is that ngrx / store covers functional programming, which limits what can be done inside a function, to achieve greater intelligence beyond it. In ngrx / store, things like reducers (hereinafter referred to as reducers), selectors (hereafter called selectors), and RxJS operators are pure functions.



Pure functions are easier to test, debug, analyze, parallelize and combine. The function is clear if:





Side effects cannot be avoided, but they are isolated in the ngrx / store, so the rest of the application may consist of pure functions.



Side effects



When the user submits the form, we need to make changes on the server. Changing the server and responding to the client is a side effect. This can be processed in the component:



this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data) // POST    .pipe(map(res => this.store.dispatch({ type: 'DATA_SAVED' }))) .subscribe(); 


It would be nice if we could just send (dispatch) action (the action below) inside the component when the user submits the form, and process the side effect elsewhere.



Ngrx / effects is middleware for handling side effects in ngrx / store. It listens for the sent actions in the observable stream, performs side effects and returns new actions immediately or asynchronously. Returned actions are passed to the reducer.



The ability to handle side effects in the RxJS way makes the code cleaner. After sending the initial action SAVE_DATA from the component, you create an effect class to process the rest:



 @Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), ); 


This simplifies the work of the component only before sending actions and subscribing to observable.



Easy to abuse Ngrx / effects



Ngrx / effects is a very powerful solution, so it is easy to abuse. Here are some common ngrx / store anti-patterns that Ngrx / effects simplifies:



1. Duplicate status



Suppose you are working on a multimedia application, and you have the following properties in the state tree:



 export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; } 


Since audio is a media type, whenever audioPlaying is true, mediaPlaying must also be true. So, here's the question: “How can I make sure that mediaPlaying is updated when audioPlaying is updated?”



Wrong answer : use Ngrx / effects!



 @Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), ); 


The correct answer is : if the mediaPlaying state mediaPlaying completely predicted by another part of the state tree, then this is not a true state. This is a derived state. This belongs to the selector, not the store.



 audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), ); 


Now our state can remain clean and normalized , and we do not use Ngrx / effects for something that is not a side effect.



2. Coupling action c reducer



Imagine that you have these properties in your state tree:



 export interface State { items: { [index: number]: Item }; favoriteItems: number[]; } 


The user then deletes the item. When the delete request is returned, the DELETE_ITEM_SUCCESS action DELETE_ITEM_SUCCESS sent to update the status of our application. In the items reducer, a separate Item is removed from the items object. But if this item identifier was in a favoriteItems array, the item it refers to will be missing. So the question is, how can I make sure that the id is deleted from favoriteItems when sending the DELETE_ITEM_SUCCESS action?



Wrong answer : use Ngrx / effects!



 @Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), ); 


So, now we will have two actions sent one after the other, and two reducers returning new states one after another.



The correct answer is : DELETE_ITEM_SUCCESS can be processed by both the items reducer and the favoriteItems reducer.



 export function favoriteItemsReducer(state = initialState, action: Action) { switch (action.type) { case 'REMOVE_FAVORITE_ITEM': case 'DELETE_ITEM_SUCCESS': const itemId = action.payload; return state.filter(id => id !== itemId); default: return state; } } 


The purpose of the action is to separate what happened from how the state should change. What happened was DELETE_ITEM_SUCCESS . The task of the reducer is to cause a corresponding change in state.



Deleting an identifier from favoriteItems not a side effect of deleting an Item . The whole process is completely synchronized and can be processed by reductors. Ngrx / effects is not needed.



3. Querying data for a component



Your component needs data from the store, but first you need to get it from the server. The question is, how can we put the data in the store so that the component can get it?



Painful way : use Ngrx / effects!



In the component, we initiate a request by sending an action:



 ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); } 


In the effects class, we listen to GET_USERS :



 @Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), switchMap(() => this.getUsers()), map(users => ({ type: 'RECEIVE_USERS', users })), ); 


Now suppose that the user decides that a certain route is loaded too much time, so he moves from it to another. To be effective and not to download unnecessary data, we want to cancel this request. When the component is destroyed, we will cancel the subscription to the request by sending an action:



 ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); } 


Now in the effects class, we listen to both actions:



 @Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS', 'CANCEL_GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), map(([action, needUsers]) => action), switchMap( action => action.type === 'CANCEL_GET_USERS' ? of() : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))), ), ); 


Good. Now another developer adds a component that requires the same HTTP request (we will not make any assumptions about other components). The component sends the same actions in the same places. If both components are active at the same time, the first component initiates an HTTP request for its initialization. When the second component is initialized, nothing extra will happen, because needUsers will be false . Wonderful!



Then, when the first component is destroyed, it will send CANCEL_GET_USERS . But the second component still needs this data. How can we prevent the cancellation of the request? Maybe we will get the counter of all subscribers? I am not going to realize this, but I suppose you understand the point. We begin to suspect that there is a better way to manage these data dependencies.



Now suppose another component appears, and it depends on data that cannot be retrieved until user data appears in the store. It could be a web socket connection for chat, additional information about some users or something else. We do not know whether this component will be initialized before or after subscribing the other two components to users .



The best help I found for this particular script is this great post . In his example, callApiY requires that callApiX be completed. I removed the comments to make it look less frightening, but feel free to read the original post to learn more:



 @Effect() actionX$ = this.actions$.pipe( ofType('ACTION_X'), map(toPayload), switchMap(payload => this.api.callApiX(payload).pipe( map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })), catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })), ), ), ); @Effect() actionY$ = this.actions$.pipe( ofType('ACTION_Y'), map(toPayload), withLatestFrom(this.store.select(state => state.someBoolean)), switchMap(([payload, someBoolean]) => { const callHttpY = v => { return this.api.callApiY(v).pipe( map(data => ({ type: 'ACTION_Y_SUCCESS', payload: data, })), catchError(err => of({ type: 'ACTION_Y_FAIL', payload: err, }), ), ); }; if (someBoolean) { return callHttpY(payload); } return of({ type: 'ACTION_X', payload }).merge( this.actions$.pipe( ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'), first(), switchMap(action => { if (action.type === 'ACTION_X_FAIL') { return of({ type: 'ACTION_Y_FAIL', payload: 'Because ACTION_X failed.', }); } return callHttpY(payload); }), ), ); }), ); 


Now add the requirement that HTTP requests should be canceled when components no longer need them, and this will become even more complex.



  .  .  . 



So why are there so many problems with data dependency management when RxJS should do it really easy?



Although the data coming from the server is technically a side effect, I don’t think Ngrx / effects is the best way to handle this.



Components are user input / output interfaces. They show data and send actions made by it. When a component is loaded, it does not send any actions taken by this user. He wants to show the data. This is more like a subscription than a side effect.



Very often you can see applications that use actions to initiate a data request. These applications implement a special interface for observable through side effects. And, as we have seen, this interface can become very uncomfortable and cumbersome. Subscribing to, unsubscribing from and linking themselves observable is much easier.



  .  .  . 



Less painful way : the component will register its interest in the data by subscribing to it through observable



We will create observables that contain the necessary HTTP requests. We’ll see how much easier it is to manage multiple subscriptions and request chains that depend on each other using pure RxJS rather than doing it through effects.



Create these observable in the service:



 requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); 


Subscribing to users$ will be sent to both requireUsers$ and this.store.pipe(select(selectUsers)) , but data will only be received from this.store.pipe(select(selectUsers)) (an example of the muteFirst implementation and a muteFirst with her test .)



In the component:



 ngOnInit() { this.users$ = this.userService.users$; } 


Since this data dependency is now simple observable, we can subscribe and unsubscribe in the template using the async pipe, and we no longer need to send actions. If the application leaves the route of the last component subscribed to the data, the HTTP request is canceled or the web socket is closed.



The data dependency chain can be processed like this:



 requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); requireUsersExtraData$ = this.users$.pipe( withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))), filter(([users, needData]) => Boolean(users.length) && needData), tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS_EXTRA_DATA', users, }), ), share(), ); public usersExtraData$ = muteFirst( this.requireUsersExtraData$.pipe(startWith(null)), this.store.pipe(select(selectUsersExtraData)), ); 


Here is a parallel comparison of the above method with this method:



parallel comparison



The use of pure observable requires fewer lines of code and automatically unsubscribe from data dependencies along the entire chain. (I missed the finalize statements, which were originally included to make the comparison more understandable, but even without them, requests will still be canceled accordingly.)



effects like cherry cocktail

')

Conclusion



Ngrx / effects is a great tool! But consider these questions before using it:



Source: https://habr.com/ru/post/448858/



All Articles