Lesson 6

[Sprint One] Component Store

Utilising component store to make the app more reactive

PRO

Lesson Outline

Setting up Component Store

We're going to take a different path to implementing the navigation functionality - it's not the most direct one, but it is going to make our application more robust. What I want to do is utilise @ngrx/component-store to manage local state for this component (and for any other components that may end up needing some kind of state management). This isn't the full NgRx/Redux approach, component-store is a lightweight and local state management solution. It's kind of similar to just using a service to manage state, with some nice helpers built in that can manage observables for us.

I won't go into detail here, but if you feel like you want a bit of a primer on @ngrx/component-store I have a video on it here.

The reason I reach for a solution like @ngrx/component-store in a situation like this is that, for things like a login process, there are going to be multiple states involved, e.g:

  • before the auth process has begun/before the user has clicked the login button
  • during the auth process
  • after the auth process completes with either a success or failure

Now we could just ignore these states, trigger the auth, and be done with it for now. But if we take a haphazard approach now it might create more work for us later when we inevitably have to refactor it. What if we want to display an error message if the login fails? We will need some way to track the error state. What if we want to display a loading animation/spinner whilst the auth is in process? We will need some way to track the "authenticating" state.

We could implement all of this ourselves, but @ngrx/component-store just provides us with an easy way to reactively handle this situation. Let's install it now:

npm install @ngrx/component-store

Then we will create two new files inside of our home directory:

  • data-access/home.store.ts
  • data-access/home.store.spec.ts

The first file is the actual component store file that we will use to manage the state, and the second is a separate file for testing our store. Let's just set up some empty boilerplate for our store:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';

@Injectable()
export class HomeStore extends ComponentStore<never> {}

Now let's consider what tests we need to write by first determining what this store should do. If we select the login state from this store it should:

  • Return a pending state by default
  • Return an authenticating state after the authentication has been triggered
  • Return a success state if the authentication succeeds
  • Return a error state if the authentication fails

Let's see if we can write some tests for these in home.store.spec.ts. To ease into this, we are just going to start with the default pending state:

import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { HomeStore } from './home.store';

describe('HomeStore', () => {
  let service: HomeStore;

  beforeEach(() => {
    jest.restoreAllMocks();
    jest.clearAllMocks();

    TestBed.configureTestingModule({
      providers: [HomeStore],
    });
    service = TestBed.inject(HomeStore);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('login selector should initially return: pending', () => {
    const status$ = service.select((state) => state.loginStatus);

    const observerSpy = subscribeSpyTo(status$);

    expect(observerSpy.getFirstValue()).toBe('pending');
  });
});

NOTE: We also need to supply HomeStore as a provider because in our @Injectable declaration for the store we do not use the providedIn option to inject it into root. We just want this store to be available to the home component, not globally.

You will likely notice that loginStatus is erroring because our component store does not have the correct type information yet, so let's add that to the store:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';

export interface HomeState {
  loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}

@Injectable()
export class HomeStore extends ComponentStore<HomeState> {}

Now let's see how our tests go:

 FAIL  src/app/home/data-access/home.store.spec.ts
  ● HomeStore - login selector should initially return: pending

    expect(received).toBe(expected) // Object.is equality

    Expected: "pending"
    Received: undefined

Great, now let's try to implement the functionality:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';

export interface HomeState {
  loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}

const defaultState: HomeState = {
  loginStatus: 'pending',
};

@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
  constructor() {
    super(defaultState);
  }
}

Now that we are supplying some default state, our simple test passes. Now let's get into the more interesting stuff. We are going to create an effect that will modify the state in response to the observable stream from our AuthService. Let's write some more tests:

import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { AuthService } from '../../shared/data-access/auth.service';
import { HomeStore } from './home.store';

jest.mock('../../shared/data-access/auth.service');

describe('HomeStore', () => {
  let service: HomeStore;
  let authService: AuthService;

  beforeEach(() => {
    jest.restoreAllMocks();
    jest.clearAllMocks();

    TestBed.configureTestingModule({
      providers: [HomeStore, AuthService],
    });
    service = TestBed.inject(HomeStore);
    authService = TestBed.inject(AuthService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('login selector should initially return: pending', () => {
    const status$ = service.select((state) => state.loginStatus);

    const observerSpy = subscribeSpyTo(status$);

    expect(observerSpy.getFirstValue()).toBe('pending');
  });

  it('login effect should change state to authenticating once triggered', () => {
    const status$ = service.select((state) => state.loginStatus);
    const observerSpy = subscribeSpyTo(status$);

    service.login();

    expect(observerSpy.getLastValue()).toBe('authenticating');
  });
});

We are now mocking and injecting the AuthService. Since our login method doesn't exist yet, we will need to add that to run the test:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { EMPTY } from 'rxjs';

export interface HomeState {
  loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}

const defaultState: HomeState = {
  loginStatus: 'pending',
};

@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
  login = this.effect(() => EMPTY);

  constructor() {
    super(defaultState);
  }
}

Again, just returning an empty observable here just to get the test running. An effect in @ngrx/component-store actually creates an observable stream from whatever values we call it with, and then returns some modified stream. For example, this effect isn't going to have any inputs so it is hard to see, but let's imagine we had a login form that required a username/password. If we called login(credentials) from our home component, the argument supplied to this.effect((credentials$) => {}) would actually be an observable stream of the values login() is called with. So, if we call login() three times with different credentials, the effect will have a stream of those three values. Again, I'd recommend watching that video for a bit more context.

Now our test should fail with this error:

 FAIL  src/app/home/data-access/home.store.spec.ts
  ● HomeStore - login effect should change state to authenticating once triggered

    expect(received).toBe(expected) // Object.is equality

    Expected: "authenticating"
    Received: "pending"

Perfect, now the implementation:

import { tap } from 'rxjs/operators';
  login = this.effect(($) =>
    $.pipe(tap(() => this.setState({ loginStatus: 'authenticating' })))
  );

Now I'm sorry to give such a weird example if this is your first experience with component store. As I mentioned, usually we would be passing values like credentials into our effect. So, we would typically have something like this.effect((credentials$)) to represent the stream of values being passed in. In this case, login() isn't passed any values, so we just have this empty observable which we call $. This observable is still important, as it is going to be triggered every time we call this login effect.

If we check our test, we will see that it is successfully passing now. Let's continue with more tests! We are just going to keep adding one at a time because this is still our first user story, but typically I would probably just create all of these tests at once and solve them all at once.

  it('login effect should trigger the loginWithGoogle method from the AuthService', () => {
    service.login();
    expect(authService.loginWithGoogle).toHaveBeenCalled();
  });

Again, our set up here is kind of weird. Usually with an authentication process we would call some method, and it would return an observable with the success/failure. However, since we are using Firebase Authentication it is split up into two parts. We actually just listen for the authState changes that our getLoggedIn provides to see when the user has successfully authenticated, but this is not what triggers the authentication. It is the signInWithPopup that triggers the authentication, which is triggered by our loginWithGoogle method. So, what we need to do is add a test that checks that this method is called.

We should see that fail now:

  ● HomeStore - login effect should trigger the loginWithGoogle method from the AuthService

    expect(jest.fn()).toHaveBeenCalled()

    Expected number of calls: >= 1
    Received number of calls:    0

and now the implementation:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { tap } from 'rxjs/operators';
import { AuthService } from '../../shared/data-access/auth.service';

export interface HomeState {
  loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}

const defaultState: HomeState = {
  loginStatus: 'pending',
};

@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
  login = this.effect(($) =>
    $.pipe(
      tap(() => {
        this.setState({ loginStatus: 'authenticating' });
        this.authService.loginWithGoogle();
      })
    )
  );

  constructor(private authService: AuthService) {
    super(defaultState);
  }
}

Again, it is quite atypical to call a method inside of tap like this, but in this case we do want this to be triggered as a side effect of calling login. The test for this should now pass.

A Quick Refactor

PRO

Thanks for checking out the preview of this lesson!

The full version of this lesson is only available to pro members. If you would like full access to this module and all of the other pro modules on Elite Ionic you can become a pro member (or log in if you are already a member).