3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 2

Managing Local State

State that does not need to be shared

STANDARD

Managing Local State

We can broadly consider two different types of state in our application: local state and shared/global state. Local state is state that is isolated within a single location, e.g. within a single component. Shared state is state that needs be to be shared among one or more different parts of the application, e.g. with multiple components. Often shared state is made globally accessible to any part of the application, but it is also possible to restrict the sharing of state to specific features/modules.

This lesson is going to focus on local state. We have already seen an example of local state with our original example:

@Component({
    selector: `app-home`,
    template: `
        <app-greeting *ngIf="showGreeting"></app-greeting>
        <button (click)="toggleGreeting()">Toggle</button>
    `
})
export class HomeComponent {
    showGreeting = false;

    toggleGreeting(){
        this.showGreeting = !this.showGreeting;
    }
}

As long as only this HomeComponent needs to know about the current showGreeting value it can just remain as local state that only this component has access to, i.e. it can just a class member on the component itself. We can even use this same technique in other components - also displaying an app-greeting tied to a showGreeting boolean - but as long as they all maintain their own values and don’t care about what the showGreeting value is set as in other components it is local state.

When we get to our real applications, because we are generally going to be relying heavily on streams and OnPush change detection, our local state might often look something like this (this is actually a snippet from the first application we will build):

@Component({
  selector: 'app-checklist',
  templateUrl: './checklist.page.html',
  styleUrls: ['./checklist.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChecklistPage {
  @ViewChild(IonContent) ionContent!: IonContent;

  formModalIsOpen$ = new BehaviorSubject<boolean>(false);
  checklistItemIdBeingEdited$ = new BehaviorSubject<string | null>(null);

  checklistItemForm = this.fb.group({
    title: ['', Validators.required],
  });

  vm$ = this.route.paramMap.pipe(
    switchMap((paramMap) =>
      combineLatest([
        this.checklistService
          .getChecklistById(paramMap.get('id') as string)
          // just to please the TypeScript compiler for potentially undefined values
          .pipe(map((checklist) => checklist as Checklist)),
        this.checklistItemService
          .getItemsByChecklistId(paramMap.get('id') as string)
          .pipe(
            tap(() => setTimeout(() => this.ionContent?.scrollToBottom(200), 0))
          ),
        this.formModalIsOpen$,
        this.checklistItemIdBeingEdited$,
      ])
    ),
    map(([checklist, items, formModalIsOpen, checklistItemIdBeingEdited]) => ({
      checklist,
      items,
      formModalIsOpen,
      checklistItemIdBeingEdited,
    }))
  );

  // ...snip

This component has several different streams that make up its state. We have a stream indicating whether the form modal is open or not:

formModalIsOpen$ = new BehaviorSubject<boolean>(false);

We have a stream indicating which (if any) of the checklist items is being edited:

checklistItemIdBeingEdited$ = new BehaviorSubject<string | null>(null);

We have a form defined:

  checklistItemForm = this.fb.group({
    title: ['', Validators.required],
  });

Which also counts as part of the local state as it is something the user can update the value of. We also have two additional bits of state we are fetching inside of our combineLatest observable:

  vm$ = this.route.paramMap.pipe(
    switchMap((paramMap) =>
      combineLatest([
        this.checklistService
          .getChecklistById(paramMap.get('id') as string)
          // just to please the TypeScript compiler for potentially undefined values
          .pipe(map((checklist) => checklist as Checklist)),
        this.checklistItemService
          .getItemsByChecklistId(paramMap.get('id') as string)
          .pipe(
            tap(() => setTimeout(() => this.ionContent?.scrollToBottom(200), 0))
          ),
        this.formModalIsOpen$,
        this.checklistItemIdBeingEdited$,
      ])
    ),

NOTE: Don’t worry if you don’t understand the code snippet above - we will talk about it in more detail when we actually get to it.

We take the id value from the route parameters and use that to get streams of both the currently selected checklist, and the items for that checklist. This is actually shared/global state, as we are pulling these values in from a service that is shared with the rest of the application. If these streams were updated somewhere else in the application, we would receive those updates within this component. The “state” of these values can be impacted from outside of the component itself, so it is not local state.

We are then combining those two bits of state with the other two bits of local state. That means the state information available to this component consists of:

  • The open/closed state of the form modal (local)
  • The id of the checklist being edited (local)
  • The data for the checklist itself (shared)
  • The items for the selected checklist (shared)

All of our state is contained within a single vm$ stream and our template can react to the values in that stream changing. Often, what we have done above is all we will need to do to “manage” our local state.

Managing Complex Local State

However, we can run into situations where our components can become quite complex as they are dealing with a lot of state or business logic. Generally, we want to keep our components as simple as possible - primarily it should just be concerned with wiring up whatever data the template needs. Components shouldn’t be responsible for doing much “work”. When our components do start to require complex state and business logic, we might want to do something about it. Our example above is probably on the border of being too complex.

There is no set definition for when something is “too” complex, but when it becomes hard to look at a component and quickly see what it is doing, that is a good indicator that the component might be becoming too complex.

Generally, we will do one of two things to reduce the complexity of a component:

  1. If the component is taking on more responsibilities than it needs to, we could break it up into multiple components
  2. If we can’t break it up into multiple components, we can extract the state/business logic out into a dedicated service/injectable

We will focus more on the first point in the architecture module, for now let’s focus on what the second point would look like. For context, here is what the entire component might look like:

import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { IonContent, IonRouterOutlet } from '@ionic/angular';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { catchError, filter, map, share, switchMap, tap } from 'rxjs/operators';
import { ChecklistService } from '../shared/data-access/checklist.service';
import { Checklist } from '../shared/interfaces/checklist';
import { ChecklistItem } from '../shared/interfaces/checklist-item';
import { ChecklistItemService } from './data-access/checklist-item.service';

@Component({
  selector: 'app-checklist',
  templateUrl: './checklist.page.html',
  styleUrls: ['./checklist.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChecklistPage {
  @ViewChild(IonContent) ionContent!: IonContent;

  formModalIsOpen$ = new BehaviorSubject<boolean>(false);
  checklistItemIdBeingEdited$ = new BehaviorSubject<string | null>(null);

  checklistItemForm = this.fb.group({
    title: ['', Validators.required],
  });

  vm$ = this.route.paramMap.pipe(
    switchMap((paramMap) =>
      combineLatest([
        this.checklistService
          .getChecklistById(paramMap.get('id') as string)
          // just to please the TypeScript compiler for potentially undefined values
          .pipe(map((checklist) => checklist as Checklist)),
        this.checklistItemService
          .getItemsByChecklistId(paramMap.get('id') as string)
        this.formModalIsOpen$,
        this.checklistItemIdBeingEdited$,
      ])
    ),
    map(([checklist, items, formModalIsOpen, checklistItemIdBeingEdited]) => ({
      checklist,
      items,
      formModalIsOpen,
      checklistItemIdBeingEdited,
    }))
  );

  constructor(
    private route: ActivatedRoute,
    private checklistService: ChecklistService,
    private checklistItemService: ChecklistItemService,
    private fb: FormBuilder,
    public routerOutlet: IonRouterOutlet
  ) {}

  addChecklistItem(checklistId: string) {
    this.checklistItemService.add(this.checklistItemForm.value, checklistId);
  }

  editChecklistItem(checklistItemId: string) {
    this.checklistItemService.update(
      checklistItemId,
      this.checklistItemForm.value
    );
  }

  openEditModal(checklistItem: ChecklistItem) {
    this.checklistItemForm.patchValue({
      title: checklistItem.title,
    });
    this.checklistItemIdBeingEdited$.next(checklistItem.id);
    this.formModalIsOpen$.next(true);
  }

  toggleChecklistItem(itemId: string) {
    this.checklistItemService.toggle(itemId);
  }

  resetChecklistItems(checklistId: string) {
    this.checklistItemService.reset(checklistId);
  }

  deleteChecklistItem(id: string) {
    this.checklistItemService.remove(id);
  }
}

It’s not terribly complex, but it certainly would be nicer to simplify this component a bit. What we can do is move all of our “state management” concerns into a service dedicated to this component (i.e. one that is not shared globally).

This service might look something like this:

import { Injectable } from '@angular/core';
import { ParamMap } from '@angular/router';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { Checklist } from '../../shared/interfaces/checklist';
import { ChecklistService } from '../../shared/data-access/checklist.service';
import { ChecklistItemService } from './checklist-item.service';

@Injectable()
export class ChecklistItemStore {
  public formModalIsOpen$ = new BehaviorSubject<boolean>(false);
  public checklistItemIdBeingEdited$ = new BehaviorSubject<string | null>(null);

  constructor(
    private checklistService: ChecklistService,
    private checklistItemService: ChecklistItemService
  ) {}

  getChecklist(paramMap: Observable<ParamMap>) {
    return paramMap.pipe(
      switchMap((params) =>
        combineLatest([
          this.checklistService
            .getChecklistById(params.get('id') as string)
            .pipe(map((checklist) => checklist as Checklist)),
          this.checklistItemService.getItemsByChecklistId(
            params.get('id') as string
          ),
        ])
      )
    );
  }
}

What we have done here is pulled out all of our streams into this separate service called ChecklistItemStore. The formModalIsOpen$ and checklistItemIdBeingEdited$ streams are so simple that this hardly makes much of a difference for them. However, for the streams we are retrieving from the services, this does allow us to pull out a significant chunk of logic from our component.

Then, in our component, we would provide this “store” directly to the component in the component’s metadata:

@Component({
  selector: 'app-checklist',
  templateUrl: './checklist.page.html',
  styleUrls: ['./checklist.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [ChecklistItemStore],
})
export class ChecklistPage {

and then we can utilise it to simplify our component:

import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { IonContent, IonRouterOutlet } from '@ionic/angular';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { ChecklistItem } from '../shared/interfaces/checklist-item';
import { ChecklistItemService } from './data-access/checklist-item.service';
import { ChecklistItemStore } from './data-access/checklist.store';

@Component({
  selector: 'app-checklist',
  templateUrl: './checklist.page.html',
  styleUrls: ['./checklist.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [ChecklistItemStore],
})
export class ChecklistPage {
  @ViewChild(IonContent) ionContent!: IonContent;

  checklistItemForm = this.fb.group({
    title: ['', Validators.required],
  });

  vm$ = combineLatest([
    this.store.getChecklist(this.route.paramMap),
    this.store.formModalIsOpen$,
    this.store.checklistItemIdBeingEdited$,
  ]).pipe(
    map(
      ([[checklist, items], formModalIsOpen, checklistItemIdBeingEdited]) => ({
        checklist,
        items,
        formModalIsOpen,
        checklistItemIdBeingEdited,
      })
    )
  );

  constructor(
    private route: ActivatedRoute,
    private checklistItemService: ChecklistItemService,
    private fb: FormBuilder,
    public routerOutlet: IonRouterOutlet,
    public store: ChecklistItemStore
  ) {}

  addChecklistItem(checklistId: string) {
    this.checklistItemService.add(this.checklistItemForm.value, checklistId);
  }

  editChecklistItem(checklistItemId: string) {
    this.checklistItemService.update(
      checklistItemId,
      this.checklistItemForm.value
    );
  }

  openEditModal(checklistItem: ChecklistItem) {
    this.checklistItemForm.patchValue({
      title: checklistItem.title,
    });
    this.store.checklistItemIdBeingEdited$.next(checklistItem.id);
    this.store.formModalIsOpen$.next(true);
  }

  toggleChecklistItem(itemId: string) {
    this.checklistItemService.toggle(itemId);
  }

  resetChecklistItems(checklistId: string) {
    this.checklistItemService.reset(checklistId);
  }

  deleteChecklistItem(id: string) {
    this.checklistItemService.remove(id);
  }
}

It’s not a huge difference, but I do think this does make this component significantly cleaner/easier to understand. Our vm$ definition now looks a lot neater:

  vm$ = combineLatest([
    this.store.getChecklist(this.route.paramMap),
    this.store.formModalIsOpen$,
    this.store.checklistItemIdBeingEdited$,
  ]).pipe(
    map(
      ([[checklist, items], formModalIsOpen, checklistItemIdBeingEdited]) => ({
        checklist,
        items,
        formModalIsOpen,
        checklistItemIdBeingEdited,
      })
    )
  );

And since we are doing most of the stream manipulation in the service now, we have been able to remove a bunch of RxJS imports from our component as well. The more complex our state management concerns for local state in a component become, the more an approach like this would pay off.

We can do this manually as we have done above - creating a service dedicated to just this one component and providing it directly to this one component (instead of using providedIn: root like we typically do that shares a service with the entire application). But, if your state is getting complex enough to warrant abstracting out into a dedicated service like this, this is the time I would reach for a simple state management library like NgRx Component Store.

We will talk a little about NgRx Component Store later, but personally I wouldn’t generally do what we have done above myself. This is the exact point I would reach for a library like NgRx Component Store, which I generally use as my go to approach for simple reactive state management. As we will discuss later, it uses basically the same concept we have used above, it just comes with a bunch of stuff built in to make your life a little easier…

But! This technique of providing a service directly to a component is also just generally useful for other scenarios, so it is a good thing to know how to do generally.