🎃

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

2020/11/10に公開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が増えても変わらず一つに対応できるので、どんどん使っていきたいですね。

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

Discussion

lacolacolacolaco

SIngle State Streamパターンの実践を共有していただいてありがとうございます!

最後のこの部分でひとつコメントさせてもらうと、

      map(value => ({
        users: value[0],
	isLoading: value[1] // 少し不思議な書き方ですが、型がついているので安心です。
      }));

不思議な書き方に見える部分は、配列からの分割代入を使うと名前付けも可能で読みやすくなります。プロパティ名をそのまま仮引数名にすることでオブジェクトリテラルの記述もかんたんになります。

      map(([users, isLoading]) => ({ users, isLoading }));

もしよかったら使ってください!

Yuito SatoYuito Sato

lacolacoさんありがとうございます!

その方式を試してみたのですが、型がうまく当たらずコンパイルエラーとなってしまいました。

rxjsのバーションがおかしかったのかもしれません。
また今度試してみます!