AngularのtoObservableをイベント列として使う実装をレビューで止める
AngularのtoObservableをイベント列として使う実装をレビューで止める
Signal を Observable に変換できると分かると、Signal をイベントの発生源として扱いたくなる。toObservable で受けて concatMap で1件ずつ処理する、という形は自然に見える。
この使い方は成立しない。toObservable が通知するのは Signal の現在値であって、更新の履歴ではないからだ。
連続更新したときに流れる値
toObservable は内部で effect を使って Signal の変更を検知し、非同期に通知する。Angular の変更検知が安定するまでの間に複数回更新された場合、途中の値は通知されない。
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 で受ける。
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回とも処理される。
@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度だけ作る形にする。
初期値の扱いも確認しておきたい。initialValue も requireSync も指定しない場合、最初の通知が届くまでの値は undefined になる。型にも undefined が現れるので、テンプレート側で扱う必要がある。
requireSync: true は、購読した瞬間に同期で値が出ることを要求する。BehaviorSubject のように現在値を持つ相手であれば使えるが、同期で出なければ実行時エラーになる。HttpClient の結果に指定すると失敗する。
@Reviewer: `requireSync: true` を指定していますが、この source は HTTP なので同期では値を出しません。実行時エラーになります。`initialValue` を指定する形に変えてください。エラーの現れ方
toSignal は、source のエラーを購読の時点では投げない。Signal を読んだときに投げる。
テンプレートから読んでいれば、通信が失敗した瞬間に描画が失敗する。try で囲める場所でもないため、エラーを画面に出したいなら、Signal へ変換する前に RxJS 側で値へ変換しておく。
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回渡すだけ括弧の有無で挙動が変わるが、どちらも型が通る。レビューで気づけるかどうかの差が出やすい箇所になる。
@Reviewer: `store.search(this.keyword())` はその時点の値を1回渡すだけです。入力の変更に追従させたいのであれば、`store.search(this.keyword)` と Signal をそのまま渡してください。レビュー観点チェックリスト
toObservableの結果を1件ずつのイベント列として扱っていないか- 「いまの値」と「起きたこと」のどちらを扱いたいかが選択と一致しているか
toSignalをゲッターやメソッドの中で作っていないかinitialValueもrequireSyncも無い箇所で、undefinedが扱われているかrequireSync: trueの source が、購読時に同期で値を出すか- source のエラーを、Signal を読む前に値へ変換してあるか
- NgRx Store で
selectSignal()を使わずにtoSignalを挟んでいないか rxMethodへ Signal を渡すつもりで、値を渡していないか
おわりに
Signal と Observable の変換は、型の上では素直につながる。つながるからこそ、どちらの契約で考えているのかが曖昧になりやすい。
レビューで確認したいのは、変換した先で何を期待しているかである。履歴を期待して toObservable を使っているなら、その期待は満たされない。現在値だけでよいなら、変換は正しく働く。