Search code examples
angulartypescriptrxjsrxjs6rxjs-observables

RxJS shareReplay() does not emit updated value


I have a user service which allows login, logout and maintains data about the currently logged in user:

user$ = this.http.get<User>('/api/user')
            .pipe(
              shareReplay(1),
            );

I am using shareReplay(1) because I do not want the webservice to be called several times.

On one of the components, I have this (for simplicity), but I have several other things I want to do if a user is logged in:

<div *ngIf="isUserLoggedIn$ | async">Logout</div>
isUserLoggedIn$ = this.userService.user$
                    .pipe(
                      map(user => user != null),
                      catchError(error => of(false)),
                    );

However, the isLoggedIn$ does not change after the user logs in or logs out. It does change when I refresh the page.

Here's my logout code:

logout() {
  console.log('logging out');
  this.cookieService.deleteAll('/');

  this.user$ = null;

  console.log('redirecting');
  this.router.navigateByUrl('/login');
}

I understand that the internal observable is not reset if I assign the variable to null.

So, for logout, I took clue from this answer: https://stackoverflow.com/a/56031703 about refreshing a shareReplay(). But, the user$ being used in the templates causes my application to go into a tizzy as soon as I attempt to logout.

In one of my attempts, I tried BehaviorSubject:

user$ = new BehaviorSubject<User>(null);

constructor() {
  this.http.get<User>('/api/user')
    .pipe(take(1), map((user) => this.user$.next(user))
    .subscribe();
}

logout() {
  ...
  this.user$.next(null);
  ...
}

This works a little better except when I refresh the page. The auth-guard (CanActivate) always gets the user$ as null and redirects to the login page.

This seemed like an easy thing to do when I started out, but I am going on falling into a deeper hole with each change. Is there a solution to this?


Solution

  • I was finally able to resolve my problem. And I understood that this is the best example of the A & B problem. For a problem A I thought that the solution is B and started researching into that, however, the solution was actually C.

    I hope I understand RxJS in a better way than how it was around 1.5 years ago. I'm putting my answer here so that it helps someone.

    To recap, my requirement was simple - If a user lands on my angular app, the AuthGuard should be able to fetch and identify the user using the cookie. If the cookie is expired, then it should redirect the user to the login page.

    I think this is a pretty common scenario and RxJS is a great approach to solve this.

    Here is how I implemented it now:

    An api /api/user sends a request to the server. Server uses the auth token in the cookie to identify the user.

    This can lead to two scenarios:

    1. the cookie is still active and the server returns the profile data of the user. I store this in a private member for later use.

    private userStoreSubject = new BehaviorSubject<User | null>(null);

    1. the cookie has expired and the server could not fetch the data (server throws a 401 Unauthorized error).

    Here's how the user profile is retrieved:

      private userProfile$ = this.http.get<User>('/api/user').pipe(
        switchMap((user) => {
          this.userStoreSubject.next(user);
          return this.userStoreSubject;
        }),
        catchError(() => throwError('user authentication failed')),
      );
    

    Note that if the server returns an error (401, that is), it is caught in catchError() which in turn throws the error again using throwError(). This is helpful in the next step.

    Now, since we know how to fetch the user profile from the server and have a BehaviorSubject to save the currently active user, we can use that to create a member to make the user available.

      user$ = this.userStoreSubject.pipe(
        switchMap((user) => {
          if (user) {
            return of(user);
          }
    
          return this.userProfile$;
        }),
        catchError((error) => {
          console.error('error fetching user', error);
          return of(null);
        }),
      );
    

    Notice the use of switchMap() because we are returning an observable. So, the code above simply boils down to:

    1. Read userStoreSubject
    2. If user is not null, return the user
    3. If user is null, return userProfile$ (which means that the profile will be fetched from the server)
    4. If there is an error (from userProfile$), return null.

    This enables us to have an observable to check if the user is logged in:

      isUserLoggedIn$ = this.userStoreSubject.pipe(
        map((user) => !!user),
      );
    

    Note that this reads from userStoreSubject and not user$. This is because I do not want to trigger a server read while trying to see if the user is logged in.

    The logout function is simplified too. All I need to do is to make the user store null and delete the cookie. Deleting the cookie is important, otherwise fetching the profile will retrieve the same user again.

      logout() {
        this.cookieService.delete('authentication', '/', window.location.hostname, window.location.protocol === 'https:', 'Strict');
        this.userStoreSubject.next(null);
        this.router.navigate(['login']);
      }
    

    And now, my AuthGuard looks like this:

    
      canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
        return this.userService.user$
          .pipe(
            take(1),
            map((user) => {
              if (!user) {
                return this.getLoginUrl(state.url, user);
              }
    
              return true;
            }),
            catchError((error) => {
              console.error('error: ', error);
              return of(this.getLoginUrl(state.url, null));
            })
          );
      }