なになれ

IT系のことを記録していきます。

Angularにおける状態管理をRxJSで試した

状態管理はフロントエンドで必要な機能としてよく取り上げられています。
ReactにおけるReduxが有名で、Reduxライクな状態管理のライブラリが各種フレームワークで用意されていて、それらを使うのが主流なようです。

AngularでもReduxライクなngrxというライブラリがあります。
ただ自分はReduxの経験がないので、パッと見で複雑そうな印象がありました。
ngrx以外の手段で状態管理を試してみました。

どのように状態管理を行うか

参考にしたのはこちらです。 slides.com coryrylan.com

「RxJSを活用したAngularアプリの状態管理設計」では状態の同期を取るという方法が紹介されています。
これを参考にして、今回はPersistent stateとServer stateを同期しつつ、stateをComponentで扱うことを試します。
stateの同期にはRxJSの機能であるBehaviorSubjectを利用することが紹介されています。
BehaviorSubjectを使えば、任意のタイミングでデータを流すことができてかつ、流れたデータを保持することが可能です。

実装

Angularの公式チュートリアルでおなじみのTour of Heroesを元にしました。
github.com

hero.service.tsを変更して、Server stateと同期するPersistent stateを実装しました。

hero.service.ts

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, throwError as observableThrowError, BehaviorSubject, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';

import { Hero } from './hero';

@Injectable()
export class HeroService {
  private _heroes: BehaviorSubject<Hero[]>;
  private heroes: Observable<Hero[]>;
  private heroesUrl = 'app/heroes'; // URL to web api

  constructor(private http: HttpClient) {
    this._heroes = new BehaviorSubject([]);
  }

  fetchHeroes(): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        switchMap(data => {
          this._heroes.next(data);
          this.heroes = this._heroes.asObservable();
          return this.heroes;
        })
      );
  }

  getHeroes(): Observable<Hero[]> {
    return this.heroes || this.fetchHeroes();
  }

  getHero(id: number): Observable<Hero> {
    return this.getHeroes().pipe(
      map(heroes => heroes.find(hero => hero.id === id))
    );
  }

  save(hero: Hero) {
    if (hero.id) {
      return this.put(hero);
    }
    return this.post(hero);
  }

  delete(hero: Hero) {
    const url = `${this.heroesUrl}/${hero.id}`;

    return this.http.delete<Hero>(url)
      .pipe(
        tap(() => {
          const current = this._heroes.getValue().filter(h => h !== hero);
          this._heroes.next(current);
        }),
        catchError(this.handleError)
      );
  }

  // Add new Hero
  private post(hero: Hero) {
    return this.http
      .post<Hero>(this.heroesUrl, hero)
      .pipe(
        tap((h) => {
          const current = this._heroes.getValue();
          this._heroes.next([...current, h]);
        }),
        catchError(this.handleError)
      );
  }

  // Update existing Hero
  private put(hero: Hero) {
    const url = `${this.heroesUrl}/${hero.id}`;
    return this.http
      .put<Hero>(url, hero)
      .pipe(
        tap(() => {
          const current = this._heroes.getValue()
            .map(h => {
              if (h.id === hero.id) {
                h = hero;
              }
              return h;
            });
          this._heroes.next(current);
        }),
        catchError(this.handleError)
      );
  }

  private handleError(res: HttpErrorResponse | any) {
    console.error(res.error || res.body.error);
    return observableThrowError(res.error || 'Server error');
  }
}

CRUDに当たる各メソッドでBehaviorSubjectのオブジェクトである_heroesプロパティにnextメソッドでデータを流しています。
Serverへリクエストを発行するとともにそのデータを_heroesに保持することで、Server stateとPersistent stateを同期しています。

また、BehaviorSubjectではnextメソッドを実行したタイミングでsubscribeメソッドが呼ばれるので、ComponentからPersistent stateを参照するのも簡単です。

heroes.component.ts

...
  ngOnInit(): void {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService
      .getHeroes()
      .subscribe(
        heroes => this.heroes = heroes,
        error => this.error = error
      )
  }

  deleteHero(hero: Hero, event: any): void {
    event.stopPropagation();
    this.heroService.delete(hero).subscribe(res => {
      if (this.selectedHero === hero) {
        this.selectedHero = null;
      }
    }, error => (this.error = error));
  }
...

BehaviorSubjectのsubscribeメソッドが実行される流れです。

  1. データを削除するためにComponentクラスのdeleteHeroメソッドが呼ばれる
  2. HeroServiceクラスのdeleteメソッドが呼ばれる
  3. BehaviorSubjectのnextメソッドが呼ばれる
  4. Componentクラスで定義したthis.heroService.getHeroes().subscribeの処理が呼ばれる

結果的に削除後のデータがBehaviorSubjectによってComponentクラスのheroesプロパティからも参照されます。

まとめ

BehaviorSubjectが有能でした。
BehaviorSubjectが保持するデータとServerのデータを同期するようにServiceクラスを作り、ComponentクラスはそのBehaviorSubjectからのみデータを取得するように設計すれば、単方向のデータフローが実現できて、シンプルな状態管理を実現できそうです。