Search code examples
angulartypescriptreduxrxjsngrx

What is the proper way to chain action in rxjs / ngrx effects


I'm a bit struggling here : I'm inside an ngrx effect, i want to authenticate with my service, and with my answer from my service, dispatch to actions to retrieve information and only then dispatch an action of type "YEAH LOGIN IS OK"

this is my code so far
    this.actions$.pipe(
      ofType(AuthActions.QuickLogin),
      switchMap((action: any) =>
        this.authService.postQuickLogin$(action.QuickVerifString).pipe(
          switchMap(resultService => {
            const props = {
              username: resultService['username'],
              token: resultService['token'],
              isAuthenticated: true
            }
            this.localStorageService.setItem(AUTH_KEY, props)
            return [
              MoMenuActions.moMenuHttpGetListAction({ US_ID: props.username }),
              UserActions.userHttpGetInfoAction({ US_ID: props.username }),
              AuthActions.LoginSucceed(props)
            ]
          }),
          catchError(error => of(AuthActions.LoginError({ error })))
        )
      )
    )

this was working well. Untill i face the issue where i get http error inside momenuaction and useraction and i'm not entering my catch error. Which is normal since switchMap cancel the previous observable and only take the lastn one. I could do map then map then map LoginSucceed but in this case i won't have the props to dispatch my LoginSucceed

So i'm not only looking for a way to do that, but looking for the "good" / proper way to do that.

If someone has any solution and explanation of why?


Solution

  • Please take a look at this amazing article from Victor Savkin about Patterns and Techniques for NgRx. Specially the Splitter and Aggregator patterns:

    Splitter

    A splitter maps one action to an array of actions, i.e., it splits an action.

    class TodosEffects {   
      constructor(private actions: Actions) {}
    
      @Effect() addTodo =
      this.actions.typeOf('REQUEST_ADD_TODO').flatMap(add => [
        {type: 'ADD_TODO', payload: add.payload},
        {type: 'LOG_OPERATION', payload: {loggedAction: 'ADD_TODO', payload: add.payload}}   
      ]); 
    } 
    

    This is useful for exactly the same reasons as splitting a method into multiple methods: we can test, decorate, monitor every action independently.

    Aggregator

    An aggregator maps an array of actions to a single action.

    class TodosEffects {   
      constructor(private actions: Actions) {}
    
      @Effect() aggregator = this.actions.typeOf(‘ADD_TODO’).flatMap(a =>
        zip(
          // note how we use a correlation id to select the right action
          this.actions.filter(t => t.type == 'TODO_ADDED' && t.payload.id === a.payload.id).first(),
          this.actions.filter(t => t.type == ‘LOGGED' && t.payload.id === a.payload.id).first()
        )   
      ).map(pair => ({
        type: 'ADD_TODO_COMPLETED',
        payload: {id: pair[0].payload.id, log: pair[1].payload}   
      })); 
    } 
    

    Aggregator are not as common as say splitters, so RxJs does not come with an operator implementing it. That’s why we had to add some boilerplate to do it ourselves. But could always introduce a custom RxJS operator to help with that.

    ...

    Based on that, the idea is to make effects to be as small as possible so that they can be tested and reused easily.

    So for example, let's pretend that there's a SIGN_IN action that involves:

    1. Calling an API to get the access token (GET_TOKEN => GET_TOKEN_SUCCESS or GET_TOKEN_FAIL)
    2. Calling another API to get the user details (GET_DETAILS => GET_DETAILS_SUCCESS or GET_DETAILS_FAIL)

    Once both actions succeeded, we can dispatch the SIGN_IN_SUCCESS action. But if any of them fails we need to dispatch the SIGN_IN_FAIL action instead.

    The actions would look like this:

    // Sign In
    export const SIGN_IN = 'Sign In';
    export const SIGN_IN_FAIL = 'Sign In Fail';
    export const SIGN_IN_SUCCESS = 'Sign In Success';
    
    export class SignIn implements Action {
      readonly type = SIGN_IN;
      constructor(public payload: { email: string; password: string; correlationParams: CorrelationParams }) {}
    }
    
    export class SignInFail implements Action {
      readonly type = SIGN_IN_FAIL;
      constructor(public payload: { message: string }) {}
    }
    
    export class SignInSuccess implements Action {
      readonly type = SIGN_IN_SUCCESS;
      constructor(public payload: { tokenDetails: Token; userDetails: User; }) {}
    }
    
    // Get Token
    export const GET_TOKEN = 'Get Token';
    export const GET_TOKEN_FAIL = 'Get Token Fail';
    export const GET_TOKEN_SUCCESS = 'Get Token Success';
    
    export class GetToken implements Action {
      readonly type = GET_TOKEN;
      constructor(public payload: { email: string; password: string; correlationParams: CorrelationParams }) {}
    }
    
    export class GetTokenFail implements Action {
      readonly type = GET_TOKEN_FAIL;
      constructor(public payload: { message: string; correlationParams: CorrelationParams }) {}
    }
    
    export class GetTokenSuccess implements Action {
      readonly type = GET_TOKEN_SUCCESS;
      constructor(public payload: { tokenDetails: Token; correlationParams: CorrelationParams }) {}
    }
    
    // Get Details
    export const GET_DETAILS = 'Get Details';
    export const GET_DETAILS_FAIL = 'Get Details Fail';
    export const GET_DETAILS_SUCCESS = 'Get Details Success';
    
    export class GetDetails implements Action {
      readonly type = GET_DETAILS;
      constructor(public payload: { correlationParams: CorrelationParams }) {}
    }
    
    export class GetDetailsFail implements Action {
      readonly type = GET_DETAILS_FAIL;
      constructor(public payload: { message: string; correlationParams: CorrelationParams }) {}
    }
    
    export class GetDetailsSuccess implements Action {
      readonly type = GET_DETAILS_SUCCESS;
      constructor(public payload: { userDetails: User; correlationParams: CorrelationParams }) {}
    }
    

    Please notice the correlationParams: CorrelationParams part of the payload. The correlationParams object allows us to know if different actions like SIGN_IN, GET_TOKEN and GET_DETAILS are related to the same sign in process or not (to be able to apply the splitter and aggregator techniques).

    The definition of that class (and an operator that will be used in the effects) is the following:

    // NgRx
    import { Action } from '@ngrx/store';
    
    // UUID generator
    // I'm using uuid as the id but you can use anything else if you want!
    import { v4 as uuid } from 'uuid'; 
    
    export class CorrelationParams {
      public correlationId?: string;
    
      public static create(): CorrelationParams {
        const correlationParams: CorrelationParams = {
          correlationId: uuid(),
        };
    
        return correlationParams;
      }
    
      public static fromAction(action: AggregatableAction): CorrelationParams {
        return action && action.payload && action.payload.correlationParams
          ? action.payload.correlationParams
          : null;
      }
    }
    
    export type AggregatableAction = Action & { payload?: { correlationParams?: CorrelationParams } };
    
    export const filterAggregatableAction = (
      sourceAction: AggregatableAction,
      anotherAction: AggregatableAction,
    ) => {
      const sourceActionCorrelationParams = CorrelationParams.fromAction(sourceAction);
      const anotherActionCorrelationParams = CorrelationParams.fromAction(anotherAction);
    
      return (
        sourceActionCorrelationParams &&
        anotherActionCorrelationParams &&
        sourceActionCorrelationParams.correlationId === anotherActionCorrelationParams.correlationId
      );
    };
    

    So when dispatching the SIGN_IN action, we need to add this correlationParams to the payload, like this:

    public signIn(email: string, password: string): void {
        const correlationParams = CorrelationParams.create();
        this.store$.dispatch(
          new fromUserActions.SignIn({ email, password, correlationParams }),
        );
      }
    

    Now the interesting part, the effects!

    // Splitter: SIGN_IN dispatches GET_TOKEN and GET_DETAILS actions
    @Effect()
    signIn$ = this.actions$.pipe(
        ofType(fromUserActions.SIGN_IN),
        flatMap((action: fromUserActions.SignIn) => {
            const { email, password, correlationParams } = action.payload;
    
            return [
                new fromUserActions.GetToken({ email, password, correlationParams }),
                new fromUserActions.GetDetails({ correlationParams }),
            ];
        }),
    );
    
    // Gets the token details from the API
    @Effect()
    getToken$ = this.actions$.pipe(
        ofType(fromUserActions.GET_TOKEN),
        switchMap((action: fromUserActions.GetToken) => {
            const { email, password, correlationParams } = action.payload;
    
            return this.userService.getToken(email, password).pipe(
                map(tokenDetails => {
                    return new fromUserActions.GetTokenSuccess({ tokenDetails, correlationParams });
                }),
                catchError(error => {
                    const message = ErrorHelpers.getErrorMessageFromHttpErrorResponse(error);
                    return of(new fromUserActions.GetTokenFail({ message, correlationParams }));
                }),
            );
        }),
    );
    
    // Gets the user details from the API
    // This action needs to wait for the access token to be obtained since
    // we need to send the access token in order to get the user details
    @Effect()
    getDetails$ = this.actions$.pipe(
        ofType(fromUserActions.GET_DETAILS),
        concatMap((action: fromUserActions.GetDetails) =>
            of(action).pipe(
                // Use combineLatest so we can wait for the token to be
                // available before getting the details of the user
                combineLatest(
                    this.store$.pipe(
                        select(fromUserSelectors.getAccessToken),
                        filter(accessToken => !!accessToken),
                        take(1),
                    ),
                ),
            ),
        ),
        switchMap(([action, _]) => {
            const { correlationParams } = action.payload;
    
            return this.userService.getDetails().pipe(
                map(userDetails => {
                    return new fromUserActions.GetDetailsSuccess({ userDetails, correlationParams });
                }),
                catchError(error => {
                    const message = ErrorHelpers.getErrorMessageFromHttpErrorResponse(error);
                    return of(new fromUserActions.GetDetailsFail({ message, correlationParams }));
                }),
            );
        }),
    );
    
    // Aggregator: SIGN_IN_SUCCESS can only be dispatched if both GET_TOKEN_SUCCESS and GET_DETAILS_SUCCESS were dispatched
    @Effect()
    aggregateSignIn$ = this.actions$.pipe(
        ofType(fromUserActions.SIGN_IN),
        switchMap((signInAction: fromUserActions.SignIn) => {
            // GetTokenSuccess
            let action1$ = this.actions$.pipe(
                ofType(fromUserActions.GET_TOKEN_SUCCESS),
                filter((getTokenAction: fromUserActions.GetTokenSuccess) => {
                    return filterAggregatableAction(signInAction, getTokenAction);
                }),
                first(),
            );
    
            // GetDetailsSuccess
            let action2$ = this.actions$.pipe(
                ofType(fromUserActions.GET_DETAILS_SUCCESS),
                filter((getDetailsAction: fromUserActions.GeDetailsSuccess) => {
                    return filterAggregatableAction(signInAction, getDetailsAction);
                }),
                first(),
            );
    
            // failAction means that something went wrong!
            let failAction$ = this.actions$.pipe(
                ofType(
                    fromUserActions.GET_TOKEN_FAIL,
                    fromUserActions.GET_DETAILS_FAIL,
                ),
                filter(
                    (
                        failAction:
                            | fromUserActions.GetTokenFail
                            | fromUserActions.GetDetailsFail
                    ) => {
                        return filterAggregatableAction(signInAction, failAction);
                    },
                ),
                first(),
                switchMap(failAction => {
                    return throwError(failAction.payload.message);
                }),
            );
    
            // Return what happens first between all the sucess actions or the first error action
            return race(forkJoin([action1$, action2$]), failAction$);
        }),
        map(([getTokenSuccess, getDetailsSuccess]) => {
            const { tokenDetails } = getTokenSuccess.payload;
            const { userDetails } = getDetailsSuccess.payload;
    
            return new fromUserActions.SignInSuccess({ tokenDetails, userDetails });
        }),
        catchError(() => {
            return of(new fromUserActions.SignInFail({ message: ErrorMessage.Unknown }));
        }),
    );
    
    

    I'm not an expert in NgRx / RxJS so there's probably a better way to handle this, but the important thing to keep in mind is the idea behind the patterns and not exactly this code snippet.