Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 217
Razgort Avatar asked Oct 11 '19 10:10

Razgort


People also ask

How does NgRx effect work?

Most effects are straightforward: they receive a triggering action, perform a side effect, and return an Observable stream of another action which indicates the result is ready. NgRx effects will then automatically dispatch that action to trigger the reducers and perform a state change.

What is effect () in Angular?

​@ngrx/effects provides an Angular actions$ service (which is also an Observable ) to emit every action that has been dispatched by your application in a single stream. Its ofType() method can be used to filter the one or more actions we're interesting in before adding a side-effect.

What are NgRx actions?

Actions are one of the main building blocks in NgRx. Actions express unique events that happen throughout your application. From user interaction with the page, external interaction through network requests, and direct interaction with device APIs, these and more events are described with actions.


1 Answers

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.

like image 103
sebaferreras Avatar answered Oct 05 '22 23:10

sebaferreras