3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 2

Creating your own store for local state

A simplified version of NgRx Component Store

EXTENDED

Creating your own store for local state

Before we learn how to use @ngrx/component-store itself, we are first going to look at an example of using a standard service that is provided directly to a component as a way to store state information.

This is the technique that is generally used with Component Store, so it will be useful to use as a baseline to compare what Component Store actually provides for us. Then, when we do add @ngrx/component-store in the next lesson, we will already have part of the work completed.

We are going to take a smart component from one of the applications we have built already and refactor it to use this service approach.

Refactoring to store state in a service

A great example to use for a refactor is the HomeComponent from the Giflist application. If you have already completed this application yourself you can follow along with this refactor. If you don’t already have the code for the Giflist application you can go back to that module and just clone the source code directly.

For reference, let’s just take a look at all of the state we are managing inside of this HomeComponent:

  currentlyLoadingGifs$ = new BehaviorSubject<string[]>([]);
  loadedGifs$ = new BehaviorSubject<string[]>([]);
  settingsModalIsOpen$ = new BehaviorSubject<boolean>(false);
  subredditFormControl = new FormControl('gifs');

  // Combine the stream of gifs with the streams determining their loading status
  gifs$ = combineLatest([
    this.redditService.getGifs(this.subredditFormControl),
    this.currentlyLoadingGifs$,
    this.loadedGifs$,
  ]).pipe(
    map(([gifs, currentlyLoadingGifs, loadedGifs]) =>
      gifs.map((gif) => ({
        ...gif,
        loading: currentlyLoadingGifs.includes(gif.permalink),
        dataLoaded: loadedGifs.includes(gif.permalink),
      }))
    )
  );

  vm$ = combineLatest([
    this.gifs$.pipe(startWith([])),
    this.settingsService.settings$,
    this.redditService.isLoading$,
    this.settingsModalIsOpen$,
  ]).pipe(
    map(([gifs, settings, isLoading, modalIsOpen]) => ({
      gifs,
      settings,
      isLoading,
      modalIsOpen,
    }))
  );

It’s not anything too overwhelming - I would say this component is still within a reasonable/manageable amount of complexity. However, when I look at this component, it definitely feels a little messy.

A good indicator for me as to whether a component is doing too much is if you can quickly understand the code by glancing at it. With all of this extra state information in the component, it is going to take a little while longer to understand what is going on. If all of the state information is separated into a service dedicated to managing state, the role of the component and the state service become a lot more clear/obvious.

Let’s get started with the refactor.

Create a file a src/app/home/data-access/home.store.ts and add the following:

import { Injectable } from '@angular/core';
import { FormControl } from '@angular/forms';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { RedditService } from '../../shared/data-access/reddit/reddit.service';

@Injectable()
export class HomeStore {
  currentlyLoadingGifs$ = new BehaviorSubject<string[]>([]);
  loadedGifs$ = new BehaviorSubject<string[]>([]);
  settingsModalIsOpen$ = new BehaviorSubject<boolean>(false);
  subredditFormControl = new FormControl('gifs');

  // Combine the stream of gifs with the streams determining their loading status
  gifs$ = combineLatest([
    this.redditService.getGifs(this.subredditFormControl),
    this.currentlyLoadingGifs$,
    this.loadedGifs$,
  ]).pipe(
    map(([gifs, currentlyLoadingGifs, loadedGifs]) =>
      gifs.map((gif) => ({
        ...gif,
        loading: currentlyLoadingGifs.includes(gif.permalink),
        dataLoaded: loadedGifs.includes(gif.permalink),
      }))
    )
  );

  constructor(private redditService: RedditService) {}

  setLoading(permalink: string) {
    // Add the gifs permalink to the loading array
    this.currentlyLoadingGifs$.next([
      ...this.currentlyLoadingGifs$.value,
      permalink,
    ]);
  }

  setLoadingComplete(permalinkToComplete: string) {
    this.loadedGifs$.next([...this.loadedGifs$.value, permalinkToComplete]);

    this.currentlyLoadingGifs$.next([
      ...this.currentlyLoadingGifs$.value.filter(
        (permalink) => !this.loadedGifs$.value.includes(permalink)
      ),
    ]);
  }
}

This refactor is actually reasonably easy - we have pretty much just copy and pasted anything related to managing the state for this component into the store service. That includes all of the class member variables related to managing state, as well as the two methods being used to update that state information.

NOTE: Notice that unlike most services we create, this @Injectable does not make use of the providedIn property. That is because we will be providing it manually.

Delete all the code we just added to the store service from HomeComponent

Now we need to update our HomeComponent to make use of the new HomeStore service.

Add the HomeStore provider directly to the HomeComponent metadata:

@Component({
  selector: 'app-home',
  // ...snip
  providers: [HomeStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})

We usually use providedIn: root to make a service globally accessible within out application. In this case, we are providing it directly to the HomeComponent because it is the only component that needs to use it. Technically, we could also just use providedIn: root and only inject HomeStore into the HomeComponent, but providing it directly to the component is a little safer. This way, we know no other parts of the application can effect the state in the store for this component (unless the HomeStore explicitly uses state from somewhere else in the application).

Inject the HomeStore in the HomeComponent:

  constructor(
    public store: HomeStore,
    public redditService: RedditService,
    private settingsService: SettingsService
  ) {}

Modify the vm$ to use the store:

  vm$ = combineLatest([
    this.store.gifs$.pipe(startWith([])),
    this.settingsService.settings$,
    this.redditService.isLoading$,
    this.store.settingsModalIsOpen$,
  ]).pipe(
    map(([gifs, settings, isLoading, modalIsOpen]) => ({
      gifs,
      settings,
      isLoading,
      modalIsOpen,
    }))
  );

Modify the template where necessary to use methods from the store

Now, our HomeComponent is a lot cleaner with all of the state management code abstracted away into its own service:

export class HomeComponent {
  vm$ = combineLatest([
    this.store.gifs$.pipe(startWith([])),
    this.settingsService.settings$,
    this.redditService.isLoading$,
    this.store.settingsModalIsOpen$,
  ]).pipe(
    map(([gifs, settings, isLoading, modalIsOpen]) => ({
      gifs,
      settings,
      isLoading,
      modalIsOpen,
    }))
  );

  constructor(
    public store: HomeStore,
    public redditService: RedditService,
    private settingsService: SettingsService
  ) {}

  loadMore(ev: Event, currentGifs: Gif[]) {
    this.redditService.nextPage(ev, currentGifs[currentGifs.length - 1].name);
  }
}

This is the sort of code that looks less chaotic and we can more easily quickly tell what is going on.

Now that we have seen what abstracting some state into a service looks like, in the next lesson we will see what it would look like by bringing @ngrx/component-store into the mix.