State Management in Angular2+

July 2017

In this article we'll discuss state management related to Angular components and services. State in a broader sense, for example state in the URL, is not discussed here.

Now let’s say your webapp is a simple list of items, where each item has a title. Then a change of state would be if you’d create a new item, update an item or remove an item. If your webapp permits it, also if you reorder the items.

Now the question is, how do we keep track of changes in the state, such that all parts of our app are synchronized and the changes are reflected in the view? This is what this tutorial is about. Basically there are two places where you can store state in an Angular app:

  1. You can store it in a service
  2. You can store it in a component

The tricky part is to know when to use which option, as this isn't always obvious. State management is an opinionated topic, so what you're about to read is my view on it.

Using services to manage state

Angular services are singletons, which makes them ideal to store data. You can be sure, that when you fetch data from a service, you’ll get the same data across the app. Now the question is, how should we store data in services, such that it is automatically updated across the entire application? The answer is observables. The concrete implementation looks a bit differently for different data types we want to store. For example, if we have an object with an id, we would store it differently from when we would store id-less data. Here's how we'd go about each of those cases.

Storing data with an id

To continue with the previous example, let’s say we fetch the items from our database and they come with an id. In a real-world application, you'll have more than one type of resources. Therefore it makes sense to build a more generic ResourceStore, such that we don't have to rewrite the boilerplate code for every resource type. The code for this generic ResourceStore looks like this:

resource.store.ts
import {Injectable} from '@angular/core';
import {Resource} from './resource';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
@Injectable()
export class ResourceStoreService {

  /**
   * Store for resources.
   * For example, the store could have such a resource:
   * resourceStore['heroes']['412'] returns BehaviorSubject with .getValue() == {id: '412', name: 'Wolverine'}
   */
  private resourceStore: ResourceStore = {};

  /**
   * Precondition: resource.uid needs to exist
   */
  addOrUpdate (resourceName: string, resource: Resource): void {

    // Initialization if not yet initialized already;
    this.resourceStore[resourceName] = this.resourceStore[resourceName] || {};

    if (this.resourceStore[resourceName][resource.uid]) {
      // push next if already initialized
      this.resourceStore[resourceName][resource.uid].next(resource);
    } else {
      // more initialization logic
      this.resourceStore[resourceName][resource.uid] = new BehaviorSubject(resource)
    }

  }

  addOrUpdateMany (resourceName: string, resources: Resource[]): void {
    resources.forEach(resource => this.addOrUpdate(resourceName, resource));
  }

  remove (resourceName: string, resourceId: string): void {
    this.resourceStore[resourceName][resourceId].complete();
  }

  get (resourceName: string, resourceId: string): BehaviorSubject<Resource> {
    return this.resourceStore[resourceName][resourceId];
  }

}

interface ResourceStore {
  [resourceName: string]: {
    [resourceId: string]: BehaviorSubject<Resource>
  };
}

Note that we’re not adding the resources directly, but instead we’re adding an observable. In case you’ve never heard of observables, you first need to familiarize yourself with the concept in order to understand this. Here's a tutorial. The observables enable us to subscribe to changes in the data, such that the application is always synchronized everywhere:

Storing id-less data in a service

Not only do services lend themselves well for objects with an id, but they are also well suited for id-less data, like our item-list. For the list, the state consists of which items are in the list and their order. So adding or removing an item as well as reordering the list would change the state of the item-list. Since the list just holds ids, updating a list item would not change the list's state, unless the list holds versioned ids. Our item-list store could look like this:

item-list.store.ts
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';

@Injectable()
export class AnimalDashboardListStore {

  private dashboardList = new BehaviorSubject([]);

  constructor() { }

  /**
   * Adds a resourceId to the list. If no position is provided, appends it to the end.
   */
  add (resourceId: string, index?: number): void {
    const currentValue = this.dashboardList.getValue();
    if (index !== undefined) {
      if (index <= currentValue.length) {
        currentValue.splice(index, 0, resourceId);
        this.dashboardList.next(currentValue);
      } else {
        throw new Error('Index of bounds. Cannot add animal.');
      }
    } else {
      currentValue.push(resourceId);
      this.dashboardList.next(currentValue);
    }
  }

  /**
   * Resets the entire list
   */
  set(newList: string[]): void {
    this.dashboardList.next(newList);
  }

  /**
   * Remove a single item from the list by its id
   */
  removeById (resourceId: string): void {
    const currentValue = this.dashboardList.getValue();
    this.dashboardList.next(currentValue.filter(id => id !== resourceId));
  }


  /**
   * Remove a single item from the list by its position
   */
  removeByIndex (index: number): void {
    const currentValue = this.dashboardList.getValue();
    currentValue.splice(index, 1);
    this.dashboardList.next(currentValue);
  }


  /**
   * Get the list-observable
   */
  get(): Observable<string[]> {
    return this.dashboardList;
  }


  /**
   * Update an item in the list by its index
   */
  updateByIndex (index: number, newResourceId: string): void {
    const currentValue = this.dashboardList.getValue();
    if (index <= currentValue.length) {
      currentValue[index] = newResourceId;
      this.dashboardList.next(currentValue);
    } else {
      throw new Error('Index of bounds. Cannot update animal.');
    }
  }

  /**
   * Update an item in the list by its id
   */
  updateById (id: string, newResourceId: string): void {
    const currentValue = this.dashboardList.getValue();
    const newValue = currentValue.map(x => x === id ? newResourceId : x);
    this.dashboardList.next(newValue);
  }

}

So we don’t have an id, but none the less, the objects from the item list can be updated through the service. In all subscribed components, the update logic for the component is then triggered when a change in the item-list store happens.

This module of the tsmean app has the full code for a service-store with id-less data.

Storing state in the component

If the state is localized to one component, it's fine to store the state on the component itself. For example, if you were to use a <spacer height="20"></spacer> component, that just inserts a div of a certain height in pixels.

import {Component, Input} from '@angular/core';

@Component({
  selector: 'spacer',
  template: `<div [style.height]="height + 'px'"></div>`
})
export class SpacerComponent {
  @Input()
  height: number;
}

To be precise, we are also storing state in the components when using services as stores:

some.component.ts
...
itemStore.subscribe(item => {this.item = item})

What's different, is that the update requests always go through the service.

Conclusion

State management is a tricky business and you need to be proficient in all methods to write maintainable and scalable applications. Here’s a quick summarization of how I would recommend to handle states:

Dear Devs: You can help Ukraine🇺🇦. I opted for (a) this message and (b) a geo targeted message to Russians coming to this page. If you have a blog, you could do something similar, or you can link to a donations page. If you don't have one, you could think about launching a page with uncensored news and spread it on Russian forums or even Google Review. Or hack some russian servers. Get creative. #StandWithUkraine 🇺🇦
Dear russians🇷🇺. I am a peace loving person from Switzerland🇨🇭. It is without a doubt in my mind, that your president, Vladimir Putin, has started a war that causes death and suffering for Ukrainians🇺🇦 and Russians🇷🇺. Your media is full of lies. Lies about the casualties, about the intentions, about the "Nazi Regime" in Ukraine. Please help to mobilize your people against your leader. I know it's dangerous for you, but it must be done!