AngularのtoObservableをイベント列として使う実装をレビューで止める

Signal を Observable に変換できると分かると、Signal をイベントの発生源として扱いたくなる。toObservable で受けて concatMap で1件ずつ処理する、という形は自然に見える。

この使い方は成立しない。toObservable が通知するのは Signal の現在値であって、更新の履歴ではないからだ。

連続更新したときに流れる値

toObservable は内部で effect を使って Signal の変更を検知し、非同期に通知する。Angular の変更検知が安定するまでの間に複数回更新された場合、途中の値は通知されない。

Signalをイベント源として扱っている実装
export class UploadComponent {
  readonly queued = signal<File | null>(null);

  readonly uploaded$ = toObservable(this.queued).pipe(
    filter((file): file is File => file !== null),
    concatMap(file => this.api.upload(file)),
@Reviewer
`toObservable` は同じ安定化期間内の連続更新では最後の値だけを通知します。複数ファイルを続けて `set` すると、途中のファイルはアップロードされません。
); onDrop(files: File[]): void { files.forEach(file => this.queued.set(file)); } }

onDrop が3つのファイルを受け取ると、set は3回呼ばれる。それでも uploaded$ に流れるのは最後の1件だけになる。Signal は更新履歴を保持しないため、通知の時点で読めるのは最後に書き込まれた値だけである。

1件ずつ確実に処理したいなら、Signal を経由させない。イベントは Subject で受ける。

イベント源をSubjectにした実装
private readonly queued$ = new Subject<File>();

readonly uploaded$ = this.queued$.pipe(
  concatMap(file => this.api.upload(file)),
);

onDrop(files: File[]): void {
  files.forEach(file => this.queued$.next(file));
}

Subject は通知ごとに値を流すため、3件が3回とも処理される。

Comment
@Reviewer: 1件ずつ処理したい要件であれば、Signal を経由させないでください。同じ安定化期間内の連続更新では中間の値が流れません。イベント源は Subject のままにするのが確実です。

判断の基準は単純にできる。扱いたいものが「いまの値」なら Signal、「起きたこと」なら Observable か Subject になる。

toSignalを作る場所

逆方向の toSignal にも、見落としやすい契約がある。toSignal は作成した時点で即座に購読する。そのため、同じ Observable から何度も作るのではなく、作った Signal を使い回す。

呼ばれるたびに購読を作る実装
get users(): Signal<User[]> {
  return toSignal(this.api.fetchUsers(), { initialValue: [] });
@Reviewer
ゲッターが呼ばれるたびに新しい購読が作られます。テンプレートから参照されるたびに通信が発生するため、フィールドで1度だけ作ってください。
}

テンプレートから参照される値をゲッターで返すと、描画のたびに評価される。toSignal を中に置けば、そのたびに購読が作られる。フィールドの初期化子で1度だけ作る形にする。

初期値の扱いも確認しておきたい。initialValuerequireSync も指定しない場合、最初の通知が届くまでの値は undefined になる。型にも undefined が現れるので、テンプレート側で扱う必要がある。

requireSync: true は、購読した瞬間に同期で値が出ることを要求する。BehaviorSubject のように現在値を持つ相手であれば使えるが、同期で出なければ実行時エラーになる。HttpClient の結果に指定すると失敗する。

Comment
@Reviewer: `requireSync: true` を指定していますが、この source は HTTP なので同期では値を出しません。実行時エラーになります。`initialValue` を指定する形に変えてください。

エラーの現れ方

toSignal は、source のエラーを購読の時点では投げない。Signal を読んだときに投げる

テンプレートから読んでいれば、通信が失敗した瞬間に描画が失敗する。try で囲める場所でもないため、エラーを画面に出したいなら、Signal へ変換する前に RxJS 側で値へ変換しておく。

エラーを値へ変換してからSignalにする
readonly usersResult = toSignal(
  this.api.fetchUsers().pipe(
    map(users => ({ ok: true as const, users })),
    catchError(() => of({ ok: false as const, users: [] })),
  ),
  { initialValue: { ok: true as const, users: [] } },
);

source が complete した後は、最後の値がそのまま保持される。complete したことは Signal 側からは分からないので、完了を検知したい要件があるなら Observable のまま扱う。

NgRx Store と併用する場合

NgRx Store を使っているなら、Selector の結果を Signal にするのに toSignal は要らない。store.selectSignal() が同じ役割を果たす。

toSignal を挟むと、undefined の扱いや購読の寿命を自分で管理することになる。使えるAPIがあるならそちらを選ぶ。

SignalStore の rxMethod も Signal を直接受け取れるため、変換は不要になる。ここで注意したいのが渡し方の違いである。

渡し方で意味が変わる
store.search(this.keyword);    // Signal を接続する。以後の変更に追従する
store.search(this.keyword());  // その時点の値を1回渡すだけ

括弧の有無で挙動が変わるが、どちらも型が通る。レビューで気づけるかどうかの差が出やすい箇所になる。

Comment
@Reviewer: `store.search(this.keyword())` はその時点の値を1回渡すだけです。入力の変更に追従させたいのであれば、`store.search(this.keyword)` と Signal をそのまま渡してください。

レビュー観点チェックリスト

Signal と RxJS を行き来する箇所の確認項目
  • toObservable の結果を1件ずつのイベント列として扱っていないか
  • 「いまの値」と「起きたこと」のどちらを扱いたいかが選択と一致しているか
  • toSignal をゲッターやメソッドの中で作っていないか
  • initialValuerequireSync も無い箇所で、undefined が扱われているか
  • requireSync: true の source が、購読時に同期で値を出すか
  • source のエラーを、Signal を読む前に値へ変換してあるか
  • NgRx Store で selectSignal() を使わずに toSignal を挟んでいないか
  • rxMethod へ Signal を渡すつもりで、値を渡していないか

おわりに

Signal と Observable の変換は、型の上では素直につながる。つながるからこそ、どちらの契約で考えているのかが曖昧になりやすい。

レビューで確認したいのは、変換した先で何を期待しているかである。履歴を期待して toObservable を使っているなら、その期待は満たされない。現在値だけでよいなら、変換は正しく働く。