Chapter 11

3-2.Single State Streamパターン

lacolaco
lacolaco
2023.09.06に更新

この章では Angular コンポーネントにおいてどのように Observable を購読すべきなのかについて学びます。単純な subscribe からはじめ、段階的に Async パイプを使ったリアクティブなパターンの使い方へステップアップしていきます。

Angular コンポーネントの基本と RxJS の基本を習得したら、次に学ぶのは実際にどうやって Observable とコンポーネントを組み合わせるかというプラクティスです。うまく Observable を利用することで、ユーザーインタラクションとリアクティブに協調するコンポーネントを簡単に実装できるようになるでしょう。

明示的な購読

最初に解説する方法は明示的な購読です。これは Observable の使い方の中でもっとも基本的で単純な方法です。これは公式チュートリアルの中でも、HeroService から値を取得するために学んだはずです。

コンポーネントを実装する前に、まずはサンプル用の DataService を用意します。これはBehaviorSubjectを持つ単純な Angular のサービスです。setValue メソッドが valueSubject に値を流し、 valueChanges getter が valueSubject を Observable として外部に公開します。

data.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class DataService {

  private valueSubject = new BehaviorSubject<any>('initial');

  get valueChanges() {
    return this.valueSubject.asObservable();
  }

  setValue(value: any) {
    this.valueSubject.next(value);
  }
}

そして AppComponent には新たな値を流すためのボタンを用意します。ここをスタートとして改めて学んでいきましょう。

app.component.ts
import { Component } from '@angular/core';
import { DataService } from './data.service';

@Component({
  selector: 'my-app',
  template: `
  <button (click)="updateValue()">Update Value</button>
  `,
})
export class AppComponent {

  constructor(private dataService: DataService) { }

  updateValue() {
    const value = new Date().toISOString();
    this.dataService.setValue(value);
  }
}

まずは DataService の値を表示するためのコンポーネントを作成します。 明示的な subscribe をおこなう ExplicitSubscribeComponent クラスは、次のように記述します。value フィールドを持っていて、テンプレート内で補間構文 {{ value }} を使って表示しています。value フィールドの更新は、 valueChanges.subscribe メソッドに渡されたコールバック関数で行われています。

explicit-subscribe.component.ts
import { Component, OnInit } from '@angular/core';
import { DataService } from '../data.service';

@Component({
  selector: 'app-explicit-subscribe',
  template: `<div>{{ value }}</div>`,
})
export class ExplicitSubscribeComponent implements OnInit {
  value: any;

  constructor(private dataService: DataService) { }

  ngOnInit() {
    this.dataService.valueChanges.subscribe(value => {
      this.value = value;
    });
  }
}

subscribe メソッドを使う方法はシンプルに見えますが注意点があります。Observable のライフサイクル で述べたように、購読解除をしなければコンポーネントが破棄されたあとにメモリリークが発生します。おそらく、ngOnDestroy ライフサイクルフックメソッドを使って次のように unsubscribe メソッドを呼ぶ方法をまず最初に思いつくでしょう。

explicit-subscribe.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { DataService } from '../data.service';

@Component({ ... })
export class ExplicitSubscribeComponent implements OnInit, OnDestroy {
  value: any;
  private subscription: Subscription;

  constructor(private dataService: DataService) { }

  ngOnInit() {
    this.subscription = this.dataService.valueChanges.subscribe(value => {
      this.value = value;
    });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

この方法はクラスフィールドとして Subscription オブジェクトを保持する必要があります。subscribe メソッドの戻り値を使うことになりますが、コンポーネントが購読する Observable が複数になると、煩雑なコードになってしまうのが欠点であり、購読解除忘れも発生しがちです。

ところで、Observable のライフサイクル で説明したとおり、Observable が完了するとその時点ですべての購読が自動的に解除されます。それを利用して、購読を開始する前の段階で、自動的に完了するように仕込んでおくことで購読解除忘れを防ぐことができます。

次の例では、ngOnDestroy ライフサイクルフックのタイミングで発行される onDestroy$ Subject を作成し、 valueChanges にはtakeUntil オペレーターを追加します。takeUntil オペレータは、引数に指定した Observable に値が流れたときに、オペレーターが適用された Observable を強制的に完了します。このように実装すると、複数の Observable になったときもクラスフィールドを増やすことなくそれぞれに takeUntil オペレーターを追加するだけです。

explicit-subscribe.component.t
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { DataService } from '../data.service';

@Component({
  selector: 'app-explicit-subscribe',
  template: `<div>{{ value }}</div>`,
})
export class ExplicitSubscribeComponent implements OnInit, OnDestroy {
  value: any;
  private onDestroy$ = new Subject();

  constructor(private dataService: DataService) { }

  ngOnInit() {
    this.dataService.valueChanges
      .pipe(takeUntil(this.onDestroy$))
      .subscribe(value => {
        this.value = value;
      });
  }

  ngOnDestroy() {
    this.onDestroy$.next();
  }
}

Async パイプを使う

ここまで明示的な購読をどのようにうまく書くかについて解説しましたが、そもそも明示的に購読しなければ、購読解除について考える必要もありません。ここからは Observable で提供されるデータをテンプレートにバインディングするときに使える Async パイプ を紹介します。基本的な方針として、Async パイプで解決できるケースでは常に Async パイプを利用し、 明示的な購読は最低限に留めましょう。 Observable をコアにしたリアクティブなアプリケーション設計を進める上で Async パイプは必需品です。

先ほどの ExplicitSubscribeComponent と同じことを Async パイプで実装するコンポーネントを、 AsyncPipeComponent という名前で次のように作成します。

async-pipe.component.ts
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from '../data.service';

@Component({
  selector: 'app-async-pipe',
  template: `<div>{{ value$ | async }}</div>`,
})
export class AsyncPipeComponent {
  value$: Observable<any>;

  constructor(private dataService: DataService) {
    this.value$ = this.dataService.valueChanges;
  }
}

びっくりするほどコードがスッキリしました。購読や購読解除に関するコードはなくなりました。同期的なvalue フィールドを廃止して、Observable 型の value$ フィールドに変更しています。value$ フィールドにはvalueChanges を代入し、テンプレートで {{ value$ | async }} のように Async パイプを適用しています。

なぜこれほどコンポーネントのコードが少なくなったかというと、明示的な購読の例で記述していた subscribe メソッドの呼び出しや unsubscribe メソッドの呼び出しを Async パイプが肩代わりしているからです。 Async パイプが適用された Observable は、そのコンポーネントのライフサイクルに合わせて自動的に購読・購読解除がおこなわれます。これにより、開発者は面倒な RxJS のお作法から解放され、ビジネスロジックだけに集中できます。たとえば、valueChanges で流れてくる値を操作したいと思ったときには、this.value$ へ代入する際にオペレーターを追加するだけでいいのです。

this.value$ = this.dataService.valueChanges.pipe(map((value) => `Value: ${value}`));

もうひとつの良いところは、 this.value$ の初期化をコンストラクタで完結できることです。 購読を開始しているわけではないため、クラスフィールドへの代入は ngOnInit まで待たなくてもよいのです。

Single State Stream パターン

Async パイプを使うときに注意するのは、同じ Observable に複数回 Async パイプを適用してしまうことです。たとえば次のようなテンプレートを書いてしまうと、同じ Observable を 2 度購読することになります。少しなら影響はありませんが、購読の数が増えるのはメモリ使用量が上昇し、本来不要な変更検知処理も実行されるので、パフォーマンスに悪影響があります。さらに、Async パイプの処理タイミングが別なので、2 つのデータバインディングの解決が常に完全に同時であることは保証されません。

<div>1. {{ value$ | async }}</div>

<div>2. {{ value$ | async }}</div>

この問題を解決するのが、筆者が Single State Stream パターン[1] と呼んでいる実装パターンです。テンプレートから参照するコンポーネントの状態を単一のストリームに合成し、Async パイプをテンプレートの先頭で 1 回だけ使うことで、その内側をあたかも同期的なテンプレートのように記述できます。

<ng-container *ngIf="state$ | async as state">
  <!-- 同期的に記述できる -->
  <div>1. {{ state.value }}</div>
  
  <div>2. {{ state.value }}</div>
  
</ng-container>

Single State Steam パターンを構成する重要な要素は ng-container タグと ngIf-as 構文です。まずはこれらを順番に紐解いていきましょう。

ng-container タグ

<ng-container> タグは Angular のテンプレート内でだけ使える特殊なタグです。このタグはテンプレート内で任意の範囲をブロック化するために使いますが、実際に DOM へレンダリングされるときには除去されます

*ngIf*ngFor のような構造ディレクティブを使うときに、テンプレート内の階層構造をうまく表現するためのタグとしてよく用いられる擬似的なタグです。

ngIf-as 構文

*ngIfas 構文は、*ngIf に渡された式の評価結果を、テンプレート内で新しい変数に代入する機能です。同期的な *ngIf では使い所はありませんが、 Async パイプを併用すれば、Async パイプで得られた値が *ngIf に渡されるタイミングで、値を変数化できるのです。

<ng-container *ngIf="user$ | async as user"> {{ user.name }} </ng-container>

しかしこのテンプレートには問題があります。user$ が null を返したとき、 *ngIf の評価は false となるため、内側のブロック全体が表示されなくなります。これを解決するには、次のように false だったときのテンプレートを指定できる*ngIfelse 構文を使う必要があります。

<ng-container *ngIf="user$ | async as user; else nullUser"> {{ user.name }} </ng-container>
<ng-template #nullUser> User is null </ng-template>

Single State Stream の合成

値が null だったときの表示は else 構文でカバーすることもできますが、テンプレートが肥大化しがちです。そもそもの原因は Observable が null を流したときに *ngIf の評価結果が false になってしまうことです。つまり、常に true になるような評価式にしておくことで else 構文を使わなくても常に同じテンプレートで Observable のデータを描画できます。

その解決法として、テンプレートが必要とする状態を単一のストリーム(Observable)に合成します。筆者はこのストリームを Single State Stream と呼んでいます。ストリームの合成には RxJS の combineLatest 関数[2]を利用します。はじめに合成されるストリームの型を State 型として定義し、コンポーネントが state$: Observable<State> フィールドを持つようにします。

async-pipe.component.ts
import { combineLatest, map } from 'rxjs';

type State = {
  value: any;
};

@Component({})
export class AsyncPipeComponent {
  // Single State Stream
  readonly state$: Observable<State>;

  constructor(private dataService: DataService) {
    // 必要なストリームを合成する
    this.state$ = combineLatest([this.dataService.valueChanges]).pipe(
      // 配列からオブジェクトに変換する
      map(([value]) => ({ value })),
    );
  }
}

今回の例では valueChanges にしか依存していませんが、複数のストリームをここで合成できます。readonly を付与しているため、この state$ フィールドの値はコンストラクタ以外で変更されません。つまりコンポーネントのインスタンスが破棄されるまで同一の Observable オブジェクトを保持することを保証します。

こうして作成した Single State Stream を、テンプレートの先頭でng-container タグの ngIf-as 構文を使って購読します。結果として、 value$ | async という式で得られていた値が、 state.value という形でアクセスできるようになります。 state は常に State 型のオブジェクトですから、 *ngIf の評価が false になることはありません。

<ng-container *ngIf="state$ | async as state">
  <div>1. {{ state.value }}</div>
  
  <div>2. {{ state.value }}</div>
  
</ng-container>

null や undefined、0"" のような *ngIf の評価結果が false になってしまう値を Async パイプで扱うときには、Single State Stream パターンを使うのが便利です。そうでない場合にも、テンプレートでの Async パイプの使い方を統一できる利点もあり、ほとんどのコンポーネントに適用できる実用的な実装パターンです。

脚注
  1. https://blog.lacolaco.net/2019/07/angular-single-state-stream-pattern/ ↩︎

  2. https://rxjs.dev/api/index/function/combineLatest ↩︎