Akita + Single State Streamパターンで行うスッキリObservable

公開:2020/11/10
更新:2020/11/10
4 min読了の目安(約3600字TECH技術記事 2

はじめに

この記事は lacolaco氏が提唱する Single State StreamパターンとAngular状態管理ライブラリ Akitaを組み合わせて Observableをスッキリさせようという記事です。
特にAkitaはloading周りの扱いのベストプラクティスを提唱していないので、そこに注目して解説していきます。

※Akitaの細かい説明はしません。

AkitaのselectLoadingの課題

Akitaのloading(ストア内が読み込み中かどうか判別してくれるフラグ)は非常に強力です。ストアが現在更新中かどうかをコンポーネントではなく、サービスとクエリから判別することが可能です。

class UserService {
  constructor(
    userStore: UserStore,
    userHttpService: UserHttpService,
  )

  fetchUsers(): Promise<void> {
    this.userStore.setLoading(true); // 読み込みを始めるためtrueに
    return this.userHttpService.fetchUsers()
      .pipe(
        tap(users => userStore.set(users)),
	tap(() => this.userStore.setLoading(false)), // 読み込み終了
	mapTo(undefined)
      );
  }
}

サービス側はとてもシンプルにかけます。これでユーザーを取得している最中はストアは読み込み中となります。

しかしコンポーネント側は下記のように 購読するObservableが増えて非常に冗長になってしまいます。

class UserComponent implements OnDestroy {
  users: User[];
  isLoading: boolean;
  onDestroy$ = new EventEmitter();
  
  constructor(
    userQuery: UserQuery,
    userService: UserService,
  ) {
    this.userQuery
      .selectAll()
      .pipe(takeUntil(this.onDestroy$))
      .subscribe(users => {
        this.users = users;
      });
    
    this.userQuery
      .selectLoading()
      .pipe(takeUntil(this.onDestroy$))
      .subscribe(isLoading => {
        this.isLoading = isLoading
      });
    
    this.userService.fetchUsers();
  }
  
  ngOnDestroy(): void {
    this.onDestroy$.next();
  }
}

また、asyncパイプパターンで書くと以下のようになります。
isLoading$がObservable booleanためのasyncパイプでの解決ができません。
(=isLoading自体がfalseの場合 ngIfがfalse評価となりその先のDOMに行き着かない)

@Component({
  templateUrl: '
    <ng-container *ngIf="isLoading$ | async as isLoading">
      <ng-container *ngIf="users$ | async as users">
      </ng-container>
    </ng-container>
  ',
})
class UserComponent implements OnDestroy {
  users$: Observable<User[]>;
  isLoading$: Observable<boolean>;
  
  constructor(
    userQuery: UserQuery,
    userService: UserService,
  ) {
    this.users$ = this.userQuery.selectAll();
    this.isLoading$ = this.userQuery.selectLoading();
    this.userService.fetchUsers();
  }
}

Single State Stream パターンによる解決

上述の通りAkitaのloadingを使うことによる課題は2つありました。

  1. 購読するObservableが増える
  2. loadingがbooleanなのでasyncパイプによる解決ができない

今回はこの課題を Single State Streamパターンで解決していきます。
細かいところは参照記事を読んでいただけると幸いです。

Single State Streamでは Observableで管理するステートをコンポーネントで一つにまとめます。
Observableを一つにまとめるときは combineLatest を使用します。

type UserComponentState = {
  users: User[];
  isLoading: boolean
};

@Component({
  templateUrl: '
    <ng-container *ngIf="state$ | async state">
      <ng-container *ngIf="!state.isLoading">
        <ul>
	  <li *ngFor="let user of state.users">
	    {{user.name}}
	  </li>
	</ul>
      </ng-container>
      <ng-container *ngIf="state.isLoading">
        <span>読込中</span>
      </ng-container>
    </ng-container>
  ',
})
class UserComponent implements OnDestroy {
  state$: Observable<UserComponentState>;
  
  constructor(
    userQuery: UserQuery,
    userService: UserService,
  ) {
    this.state$ = combineLatest([
      this.userQuery.selectAll(),
      this.userQuery.selectLoading()
    ]).pipe(
      map(value => ({
        users: value[0],
	isLoading: value[1] // 少し不思議な書き方ですが、型がついているので安心です。
      }));
    );
    this.userService.fetchUsers();
  }

かなりスッキリしましたね。まとめると以下のような効果があると考えられます。

  1. Observableが一つになり、スッキリした
  2. asyncパイプが使えるようになり購読終了処理をスキップできた

このパターンはさらに3つ4つ管理するObservableが増えても変わらず一つに対応できるので、どんどん使っていきたいですね。

読了ありがとうございましたー!