ngrx chaining actions / effects - eg login and then navigate

dannym picture dannym · Feb 21, 2017 · Viewed 7.4k times · Source

I am new to ngrx and Redux style architecture and I am having problems understanding how I should chain actions / effects. One example is achieving basic functionality such as taking an action after a user logs in. My core struggle here is that the action to be taken after the user has logged in will vary depending upon the current state of the application - the user may be anywhere in the application when presented with a login prompt / page.

Any examples I have seen hardcode effects which will happen once the user has logged in. In my scenario this does not fit well as stated above I do not always want the same action to happen.

Here is some example code of a home page which includes a login component. In this scenario I would like the user to be redirected to '/buy' once they have logged in.

@Component(..)
export class HomePageComponent {
    constructor(private store: Store<any>) {
    }

    public onLoginSubmitted(username, password) {
        this.store.dispatch(new loginActions.Login({username, password}));
        // once this has happened successfully navigate to /buy    
    }

}

Example effect

@Injectable()
export class LoginEffects {

    ......     

    @Effect()
    login$ = this.actions$
        .ofType(loginActions.ActionTypes.LOGIN)
        .switchMap((data) => {
            return this.loginService.login(data.payload)
                .map(() => new loginActions.LoginSuccess({loggedIn: true, isLoggingIn: false}))
                .catch((error) => {
                    return of(new loginActions.LoginError({loggedIn: false, isLoggingIn: false}));
                });
        });
    ......
}

I have several thoughts on how you might solve this problem - but none of them feel right. These include

  • passing data with the login payload to determine an action to take next
  • writing lots of effects which upon LoginSuccess take action but are filtered at some higher level
  • write a component specific effect by listening to events on the store ( is this possible / bad practice ? )
  • reading data from the store about the current login status and acting upon this in the component. However this logic would run immediately, which may not have the desired effect

Can anyone point me down the right path / present examples of how this should be solved?

Answer

Adam picture Adam · Feb 21, 2017

An effect can fire off multiple actions using mergeMap on an array of actions. What I would usually do is, pass in the additional actions that I would like the login effect to also dispatch.

@Component(..)
export class HomePageComponent {
    constructor(private store: Store<any>) { }

    public onLoginSubmitted(username, password) {
        this.store.dispatch(new loginActions.Login({
            username, 
            password, 
            onCompleteActions: [new loginActions.Redirect({ route: '/buy' })] }));
    }
}

The login effect would then dispatch any passed-in actions using mergeMap. You would still have to create an effect for each action, but now you can build more reusable actions, such as a redirect action.

@Injectable()
export class LoginEffects {

    ......     

    @Effect()
    login$ = this.actions$
        .ofType(loginActions.ActionTypes.LOGIN)
        .switchMap((data) => {
            let mappedActions = [new loginActions.LoginSuccess({loggedIn: true, isLoggingIn: false})];
            if (data.payload.onCompleteActions)
                mappedActions = mappedActions.concat(data.payload.onCompleteActions);

            return this.loginService.login(data.payload)
                .mergeMap(mappedActions)
                .catch((error) => {
                    return of(new loginActions.LoginError({loggedIn: false, isLoggingIn: false}));
                });
        });

    ......
}

What I like about this solution is that it is scalable into the future, since any actions can be passed in, and there's no switch or if-else tree to decide what to do. You can also pass in multiple actions, so there's no need to build a 'redirectAndCloseModal' action, you could pass in redirect as well as closeModal.

this.store.dispatch(new loginActions.Login({
    username, 
    password, 
    onCompleteActions: [new loginActions.Redirect({ route: '/buy' }), new modalActions.CloseModal()] }));

You can read a bit more about dispatching multiple Actions from one @Effect() at this github issue.

Edit: Regarding you comments, it's not just route changes that may make you want to stop the login, it could be anything, such as closing a modal, and there's no generic way to watch for that. You could add something to your store such as a runLoginActions boolean, and then read that data from your store after the login request comes back.

Then in your login$ effect, you could read that from the store to decide to run the additional actions or not.

@Effect()
login$: Observable<Action> = this.actions$
    .ofType(auth.actionTypes.LOGIN)
    .map((action: auth.LoginAction) => action.payload)
    .switchMap((user) => {
        let successAction = new loginActions.LoginSuccess({loggedIn: true, isLoggingIn: false});
        let mappedActions = [successAction];
        if (data.payload.onCompleteAction)
            mappedActions.push(data.payload.onCompleteAction);

        return this.authService.login(user.username, user.password)
            .withLatestFrom(this.store)
            .mergeMap(([result, store]) => store.runLoginActions ?
                [mappedActions] :
                [successAction]
            )
            .catch((err) => {
                try {
                    return of(new auth.NetworkFailureAction(err[0].code));
                } catch (e) {
                    throw `Array not in the expected format of [{ code: "", message: "" }]`;
                }
            });
    });

Note that in your reducer for loginSuccess, you should set your runLoginActions = true, and it should start as true by default, only to be turned off when a route is changed, or a component is destroyed.

Keep in mind this is a general solution, and you're going to need to add the actions yourself, update your reducers, etc.

It's a more of a manual process, but since your cases for not wanting to perform actions are pretty specific, it likely has to be done this way.