Angular State Management: Service with a Subject

Feb 20, 2023
Angular

All but the most trivial applications will require some form of state management. For example, Stampy our simple stamp collection tracker app stores a list of stamps. The list of stamps is initially retrieved from the backend and updated as the user adds stamps. Any component that uses the state needs to be notified when changes happen so it can update itself.

There are many libraries to manage state in Angular such as NgRx, NGXS, Elf, and others. All of these provide extremely capable feature-rich albeit complex solutions, sometimes you just need a simple solution to manage a small amount of state. The service with a subject pattern offers a decent state management solution for simple apps.

Pre-requisites

  • Familiarity with RxJs.

The Service with a Subject Approach

As the name suggests service with a subject is a service with a subject that is an observable that broadcasts to multiple listeners. When the state is mutated, e.g. a new stamp is created, the new state is emitted via the subject to the listeners. The subject is exposed to components in a read-only way i.e. as a regular observable.

In general, you’ll want to use a BehaviorSubject because it always has a value and will return the last emitted value when subscribed to. This means that if a new component subscribes to the state it will get the current state. The new state is emitted to the listeners using the next(...) method.

Example Service with a Subject

Below is the state service from Stampy. The stamp list state is stored in a BehaviorSubject called stamps$ which is initialised with an empty list []. If there was more state to be stored e.g. a list of favourites additional subjects would be created.

In the constructor, the loadStamps() method is called which makes a request to the client to retrieve the stamps. Once the stamps are retrieved the next(...) method is called on the stamps$ subject passing in the stamps notifying any listeners of the change.

The stamps$ subject is exposed to the outside via the getStamps() method. The return type of getStamps() is Observable<Stamp[]> this is to prevent access to the subject’s next(...) method which would allow classes other than the subject to mutate state.

Finally, we have addStamp(...) which like the loadStamps() interacts with the client in this case making a request to add a stamp. Once the request is complete the new stamp is added to the current list of stamps accessed using the getValue() on the subject. The next(...) method is again used to notify listeners of the change.

export class StampyStateService {

  private stamps$: BehaviorSubject<Stamp[]> = new BehaviorSubject<Stamp[]>([]);

  constructor(private client: StampyClientService) {
    this.loadStamps();
  }

  loadStamps() {
    this.client.getStamps().subscribe(stamps => this.stamps$.next(stamps));
  }

  getStamps(): Observable<Stamp[]> {
    return this.stamps$;
  }

  addStamp(name: string) {
    this.client.addStamp(name)
      .subscribe(stamp => this.stamps$.next(this.stamps$.getValue().concat([stamp])));
  }

}

Using the Service in a Component

To access the state simply inject the state service and get the stamps observable.

export class StampListComponent {

  stamps$: Observable<Stamp[]>;

  constructor(private stampService: StampyStateService) {
    this.stamps$ = this.stampService.getStamps();
  }

}

And then use it in the html as normal using the async pipe. When the state is changed the component will be re-rendered.

<div *ngFor="let stamp of stamps$ | async" class="stamp">{{stamp.name}}</div>

Testing the Service

Below is an example test of the state service. The test sets up a mock version of the client used to retrieve the stamps and provides it to the state service. With the mock set up the loadStamps() is called and the result of getStamps() is checked.

describe('StampyService', () => {
  let stampyClient = jasmine.createSpyObj('StampyClientService', ['getStamps', 'addStamp']);
  let service: StampyStateService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [{provide: StampyClientService, useValue: stampyClient}]
    });

    stampyClient.getStamps.and.returnValue(of([]));
    service = TestBed.get(StampyStateService);
  });

  it('loading stamps notifies observers', (done) => {
    stampyClient.getStamps.and.returnValue(of([new Stamp(1, 'Two Penny Blue')]));

    service.loadStamps();

    service.getStamps().subscribe(stamps => {
      expect(stamps.length).toEqual(1);
      expect(stamps[0].id).toEqual(1);
      expect(stamps[0].name).toEqual('Two Penny Blue');
      done();
    });
  });

  // ...

});

The test is asynchronous hence we need be provided the done() and call it when all the expects have completed.