7

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?

Tony Ngo
  • 19,166
  • 4
  • 38
  • 60
Razgort
  • 428
  • 1
  • 7
  • 18
  • 1
    From the code alone it seems like you should come in `catchError` when you recieve a http error from `this.authService.postQuickLogin`. Not sure how `momenuaction ` can trigger a http error, since it's just an action creator. – timdeschryver Oct 11 '19 at 15:40
  • Tim is right. Action creators cannot throw error. Error can be thrown only by network requests on these actions. So another CatchError to respective network calls code should be added. – Oleksandr Poshtaruk Oct 12 '19 at 06:43
  • Is MoMenuActions.moMenuHttpGetListAction handled by some other Effect? – Oleksandr Poshtaruk Oct 12 '19 at 06:45

2 Answers2

9

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.

sebaferreras
  • 44,206
  • 11
  • 116
  • 134
  • hey do can we exchange more about that in private message or mail ? – Razgort Oct 12 '19 at 07:48
  • It'd be better if we do that here via comments so that other users can also take a look and contribute :) – sebaferreras Oct 12 '19 at 08:13
  • Is it compatible with rxjs 6 ? / ngrx latest – Razgort Oct 12 '19 at 09:10
  • In that code I'm using RxJS 6.5.1 and NgRx 7.4.0 but yes, it should also work with the latest version of NgRx. The idea of the answer is just to show how you could implement the Splitter/Aggregator patterns using RxJS. – sebaferreras Oct 12 '19 at 09:16
  • 1
    Yes of course i started to refactor my code using your example yesterday it's ona good way and i feel so stupid not to think about it first. I'm taking some rest today and will continue tomorrow – Razgort Oct 13 '19 at 13:36
  • Glad to hear that it helped :) – sebaferreras Oct 13 '19 at 18:27
  • Hmm this is quite tricky could you provide plz an example of your token reducer and selector ? As i dont see how you can set value in token succed in the store with onyl your correlation param payload – Razgort Oct 14 '19 at 09:59
  • also you never subscribe to any observable ? – Razgort Oct 14 '19 at 10:20
  • ofType(AuthActions.LoginParamAction), switchMap((action: any) => { let action1$ = this.actions$.pipe( ofType(AuthActions.GetTokenSuccess), filter((getTokenAction: any) => { return filterAggregatableAction(action, getTokenAction) }), first() ) – Razgort Oct 14 '19 at 10:36
  • It seems i can't declare my type as an AuthAction.GetTokenSuccess – Razgort Oct 14 '19 at 10:37
  • The `fromUserActions.GetTokenSuccess` action is used in the reducer to update the token... Please take a look at the following line from the `getToken$` effect: `return new fromUserActions.GetTokenSuccess({ tokenDetails, correlationParams });` – sebaferreras Oct 14 '19 at 12:23
  • i passed those error with any and the new syntax with ltatest ngrx for action is just switchMap(([action, _]) => { return this.userService.getUserInfo(action.US_ID).pipe( map(() => { return UserActions.GetUserInfoParamSuccess(action.correlationParams) }) ) – Razgort Oct 14 '19 at 15:07
  • 1
    But can you explain me this combineLatest( this.store$.pipe( select(selectAuthToken), filter(token => !!token), take(1) ) ) i dont understand the filter token !!token whats this operator !! – Razgort Oct 14 '19 at 15:08
  • It's not an operator – `!!token` just casts `token` to be a boolean since it's a string (you can find more information about it **[here](https://stackoverflow.com/questions/784929/what-is-the-not-not-operator-in-javascript)**) – sebaferreras Oct 14 '19 at 15:46
  • So basically that `combineLatest` part allows the stream to continue only if the token is not empty. – sebaferreras Oct 14 '19 at 15:54
  • Ok i found it but my service is executed before the token state changed =( concatMap((action: any) => { console.log(action) return of(action).pipe( combineLatest( this.store$.pipe( select(selectAuth), filter(token => { console.log(token) if (token !== undefined) return true else return false }), take(1) ) ) ) }), switchMap(([action, _]) => { console.log('USE MY SERVICE') – Razgort Oct 14 '19 at 16:24
  • But it's not waiting for the token to be good to call my switchMap – Razgort Oct 14 '19 at 16:39
  • Ok i got it was juste misunderstood of the combine latest i switch to withLatestFrom( this.store$.pipe( select(selectAuth), filter(auth => { console.log(auth) if (auth !== undefined && auth.token !== undefined) return true else return false }) ) so now it does wait but it's neither reemitting value when state change .... – Razgort Oct 14 '19 at 17:20
  • Could you please take a look at **[this stackblitz demo](https://stackblitz.com/edit/angular6-rxjs6-playground-pd1mus)**?. It's just a RxJs demo where the same operators are used to wait for the token to be ready before calling the service. – sebaferreras Oct 14 '19 at 17:40
  • 1
    Thx i ll take a look first thing tomorrow =) – Razgort Oct 14 '19 at 18:00
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/200856/discussion-between-razgort-and-sebaferreras). – Razgort Oct 14 '19 at 18:28
  • hello stackoverflow asked me to move in a chatroom instead of keep repliying here do you see the chatroom ? – Razgort Oct 15 '19 at 07:56
0

Maybe you want to dispatch multiple action at same time ?

If so consider this example

@Effect()
dispatchMultiAction$: Observable<Action> = this.actions$.pipe(
    ofType<SomeAction.Dispatch>(someActions.Dispatch),
    switchMap(_ =>
        of(
            new someActions.InitData(),
            new someActions.GetData(),
            new someActions.LoadData()
        )
    )
);

It that is not the case please let me know

Tony Ngo
  • 19,166
  • 4
  • 38
  • 60
  • I want to dispatch two action and then only if both succeed dispatch a new one. But i want to be able to know if those action went well olr not or if you do a switchmap you cancel the previous observable so you won't know if it's true or false result. – Razgort Oct 11 '19 at 12:14
  • Also i want to be able to have my resultservice result when i call for the third oine wich won't be possible if i do one map after an other because i would need this resultService in the third one and not in thr first one – Razgort Oct 11 '19 at 12:14