0
votes

I'm trying to make a sequiental requests to ngrx-store to save two different entities in two random mongodb collections and need to have answer from first ngrx-store effect to use data in another one on create entity referring to first one.

What I have:

reducer

export interface UserState {
  currentUser: IUser | null;
  checkUserResult: any | null;
  user: IUser | null;
  users: IUser[] | null;
  errorMessage: string | null;
  isLoading: boolean;
  isLoaded: boolean;
  isError: boolean;
}

export function userReducer(
  state = initialUserState,
  action: fromUser.UserActions
): UserState {
  switch (action.type) {
    case fromUser.USER_CREATE: {
      return {
        ...state,
        isLoading: true,
      };
    }
    case fromUser.USER_CREATE_SUCCESS: {
      return {
        ...state,
        user: {
          _id: action.user._id,
          userLogin: action.user.userLogin,
          userPassword: action.user.userPassword,
          userEmail: action.user.userEmail,
          userPhone: action.user.userPhone,
          userRole: action.user.userRole,
          userType: action.user.userType,
          userRef: action.user.userRef,
        },
        checkUserResult: null,
        errorMessage: null,
        isLoading: false,
        isLoaded: true,
        isError: false,
      };
    }
    case fromUser.USER_CREATE_FAILURE: {
      return {
        ...state,
        errorMessage: "Can not create user",
        isError: true,
      };
    }

[...]
}

action

export class UserCreate implements Action {
  readonly type = USER_CREATE;
  constructor(public payload: IUser) {}
}
export class UserCreateSuccess implements Action {
  readonly type = USER_CREATE_SUCCESS;
  constructor(public user: IUser) {}
}
export class UserCreateFailure implements Action {
  readonly type = USER_CREATE_FAILURE;
  constructor(public error: string) {}
}

effect

@Effect()
  createUser$: Observable<Action> = this.action$.pipe(
    ofType(UserActions.USER_CREATE),
    map((action: UserActions.UserCreate) => action.payload),
    exhaustMap((payload) => {
      return this.userDataService.createUser(payload).pipe(
        map((data) => {
          return new UserActions.UserCreateSuccess(data);
        }),
        catchError((err) => of(new UserActions.UserCreateFailure(err)))
      );
    })
  );

selector

export const getUserState = createSelector(
  fromFeature.getAppState,
  (state: fromFeature.AppState) => state.users
);

and service

createUser(user: IUser) {
    this.store.dispatch(new fromStore.UserCreate(user));
    return this.store.select(fromStore.getUserState);
  }

as well as another service (user-data.service.ts) which methods calls from effect

createUser(user: IUser): Observable<IUser> {
    const url = `${this.BASE_URL}/user/create`;
    return this.http.post<IUser>(url, { ...user });
  }

all this setup works while I create only one entity from component

user-create.component.ts

onCreateUser() {
    this.us
      .createUser({
        userLogin: this.createUserForm.value.userLogin,
        userPhone: this.createUserForm.value.userPhone,
        userEmail: this.createUserForm.value.userEmail,
        userPassword: this.createUserForm.value.userPassword,
        userRole: this.createUserForm.value.userRole,
        userRef: this.createUserForm.value.userRef,
        userType: this.createUserForm.value.userType,
      })
      .subscribe((user) => {
        if (user) {
          this.createUserForm.reset();
          this.router.navigate(["/users"]);
          this.us.getUsersList();
        }
      });
  }

Other logic to create different entities are equal to listed above. And when I try to call action inside action within component like this

this.us
      .createUser({
        userLogin: this.createContactUserForm.value.userPhone,
        userPhone: this.createContactUserForm.value.userPhone,
        userEmail: this.createContactUserForm.value.userEmail,
        userPassword: this.createContactUserForm.value.userPassword,
        userRole: this.createContactUserForm.value.userRole,
        userRef: this.createContactUserForm.value.userRef,
        userType: this.createContactUserForm.value.userType,
      })
      .subscribe((userState) => {
        if (userState.user) {
          this.cs
            .createContact({
              contactFirstName: this.createContactUserForm.value
                .contactFirstName,
              contactLastName: this.createContactUserForm.value.contactLastName,
              contactPatronymicName: this.createContactUserForm.value
                .contactPatronymicName,
              contactPhone: this.createContactUserForm.value.userPhone,
              contactUserId: userState.user._id,
            })
            .subscribe((contactState) => {
              if (contactState) {
                this.router.navigate(["/contacts"]);
              }
            });
        }
      });

it works as intended, but later, when I try to make any other action - it brokes application state. For example I can't edit or delete contacts after it. On other hand if I split logic to two independent calls - it won't work, because at moment when createContact method calls - application didn't know yet createdUser _id or userState.user._id and throw an error.

I think, here must be a more complex logic, but how make it correct - I have no clue. Please, suggest me proper way to perform this.

Thanks in advance.

1

1 Answers

2
votes

You have issues related to your subscription management.

The things that I will recommend to you are more like a general idea of how you should work with NGRX

First, let's look at your "dispatcher" service / model or however people are calling the service that takes care of dispatching actions nowadays.

createUser(user: IUser) {
    this.store.dispatch(new fromStore.UserCreate(user));
    return this.store.select(fromStore.getUserState);
  }

Having a separate method to call the store is good practice, returning the store selector from there, not so much. There are two reasons for that:

  • First the possibility of a case where you want to get the data that is already stored inside the store, without loading it again is very high.
  • Second The possibility of wanting to dispatch an action without the need of a subscription to a particular field from the store is also very high.

Refactored version

user$ = this.store.select(fromStore.getUserState);
createUser(user: IUser): void {
    this.store.dispatch(new fromStore.UserCreate(user)); 
  }

Now let's take a look at the component where you are initiating the create user request

user-create.component.ts

onCreateUser() {
    this.us
      .createUser({
        userLogin: this.createUserForm.value.userLogin,
        userPhone: this.createUserForm.value.userPhone,
        userEmail: this.createUserForm.value.userEmail,
        userPassword: this.createUserForm.value.userPassword,
        userRole: this.createUserForm.value.userRole,
        userRef: this.createUserForm.value.userRef,
        userType: this.createUserForm.value.userType,
      })
      .subscribe((user) => {
        if (user) {
          this.createUserForm.reset();
          this.router.navigate(["/users"]);
          this.us.getUsersList();
        }
      });
  }

When watching this block of code in the context of your implementation, we can see that each time when we create a new user we are creating a new subscription, which means that after we create our first user and we attempt to create a second one, we will be creating a new subscription executing the code inside the subscribe method + we will be also executing the code in the old subscription, because we never unsubscribe.

Also if we already had created a user we will get inside the if(user) block before creating successfully our new user, because we already have a user entity inside our store.

Refactored

user.service.ts

user$ = this.store.select(fromStore.getUserState);
userCreatedSuccesfuly$ = this.actions$.pipe(ofType(UserActions.USER_CREATE))
createUser(user: IUser): void {
    this.store.dispatch(new fromStore.UserCreate(user)); 
 }

user-create.component.ts

onCreateUser() {
    this.us
      .createUser({
        userLogin: this.createUserForm.value.userLogin,
        userPhone: this.createUserForm.value.userPhone,
        userEmail: this.createUserForm.value.userEmail,
        userPassword: this.createUserForm.value.userPassword,
        userRole: this.createUserForm.value.userRole,
        userRef: this.createUserForm.value.userRef,
        userType: this.createUserForm.value.userType,
      })
    this.us.userCreatedSuccesfuly$.pipe(take(1))
      .subscribe(() => {
          this.createUserForm.reset();
          this.router.navigate(["/users"]);
          this.us.getUsersList();
      });
  }

So the difference here is that now instead of depending on changes inside the user field (from the store) we are depending on the success action, that will be fired after a new user had been created, also by using the take(1) operator we are making sure that there will be no memory leaks because the subscription will be killed once that the user has been created.

(side-note: there is still the possibility of a memory leak if the user creation has failed, but I don't want to go in more complex solutions)

And now for the nested subscriptions

So overall subscribe inside of subscribe is a big no-no

this.us
      .createUser({
        userLogin: this.createContactUserForm.value.userPhone,
        userPhone: this.createContactUserForm.value.userPhone,
        userEmail: this.createContactUserForm.value.userEmail,
        userPassword: this.createContactUserForm.value.userPassword,
        userRole: this.createContactUserForm.value.userRole,
        userRef: this.createContactUserForm.value.userRef,
        userType: this.createContactUserForm.value.userType,
      })
      .subscribe((userState) => {
        if (userState.user) {
          this.cs
            .createContact({
              contactFirstName: this.createContactUserForm.value
                .contactFirstName,
              contactLastName: this.createContactUserForm.value.contactLastName,
              contactPatronymicName: this.createContactUserForm.value
                .contactPatronymicName,
              contactPhone: this.createContactUserForm.value.userPhone,
              contactUserId: userState.user._id,
            })
            .subscribe((contactState) => {
              if (contactState) {
                this.router.navigate(["/contacts"]);
              }
            });
        }
      });

Refactored

user.service.ts

user$ = this.store.select(fromStore.getUserState);
userCreatedSuccesfuly$ = this.actions$.pipe(ofType(UserActions.USER_CREATE_SUCCESS))
createUser(user: IUser): void {
    this.store.dispatch(new fromStore.UserCreate(user)); 
 }

contact.service.ts

contact$ = this.store.select(fromStore.getUserState);
contactCreatedSuccesfuly$ = this.actions$.pipe(ofType(ContactActions.CONTACT_CREATE_SUCCESS))
createUser(contact: IContact): void {
    this.store.dispatch(new fromStore.ContactCreate(contact)); 
 }

some-cmp.component.ts

doSomething(){

this.us.createUser({
        userLogin: this.createContactUserForm.value.userPhone,
        userPhone: this.createContactUserForm.value.userPhone,
        userEmail: this.createContactUserForm.value.userEmail,
        userPassword: this.createContactUserForm.value.userPassword,
        userRole: this.createContactUserForm.value.userRole,
        userRef: this.createContactUserForm.value.userRef,
        userType: this.createContactUserForm.value.userType,
      })

this.us.userCreatedSuccesfuly$.pipe(
      switchMap(() => this.us.user$),
      filter(x => !!x),
      tap(userState => {
         this.cs
            .createContact({
              contactFirstName: this.createContactUserForm.value
                .contactFirstName,
              contactLastName: this.createContactUserForm.value.contactLastName,
              contactPatronymicName: this.createContactUserForm.value
                .contactPatronymicName,
              contactPhone: this.createContactUserForm.value.userPhone,
              contactUserId: userState.user._id,
            })
      }),
      switchMap(() => this.cs.contactCreatedSuccesfuly$),
      take(1)
      )
      .subscribe((userState) => {
       this.router.navigate(["/contacts"]);
      });

}

So let us go through the last block of code:

  • We create a new user
  • switchMap We refer to userCreatedSuccesfuly$ and we are saying, whenever this observable emits, switch to a new observable that is this.us.users$.
  • filter Filter the passing values in such a way that only truthy values can pass trough the stream (e.g. only if there is a user saved inside in our store)
  • tap Once a value reaches this point create a contact, by using the incoming value, without affecting the value of the current stream (e.g. create a contact with the user information)
  • switchMap Here we are referring to the this.us.users$ and we are saying whenever a value gets to this point, switch to a new observable that is this.cs.contactCreatedSuccesfuly
  • take Here we make sure that we will clear our subscription after we have succeeded with the creation of new contact
  • subscribe by calling this method we are triggering the stream

Oh boy, that was long, anyway, my advice to you is to spend some more time on learning rxjs in general