RxJS 本体は Angular 22 で動いていない。動いたのは Angular 側で、provideHttpClient() の既定が Fetch になり、withFetch() と reportProgress と JSONP に非推奨が付いた。同時に signal が状態保持の役を引き取ったため、RxJS に残るのは寿命・競合・エラーの3つになった。「BehaviorSubject で状態を持てているか」「購読を解除しているか」だけを問うリストは、どちらの前提も古い。ここでは Angular 22.2.1 と RxJS 7.8 を前提に、RxJS 運用のレビュー観点を100項目へ整理する。

対象は RxJS 7.8 系と、@angular/core/rxjs-interop 22.2.1 の takeUntilDestroyed / toSignal / toObservable / outputFromObservable / pendingUntilEvent、@angular/common/http 22.2.1 である。signal と Resource の読み取り契約はAngular 22のSignalと非同期状態をレビューする100観点、ストアの設計はNgRx StoreのAction・Reducer・Effectをレビューする100観点とNgRx SignalStoreの状態設計をレビューする100観点に分けてある。

各群は「群の問い」から始まる。差分を読みながらその問いに yes か no で答えられるかを確かめ、答えられない項目をレビューコメントへ変換する、という順で使う想定である。

Angular 22 で前提が変わったのは通信側の4点である。

項目 21 まで 22.2.1
HttpClient の既定バックエンド XHR。Fetch は provideHttpClient(withFetch()) で選ぶ Fetch が既定。withFetch() は「不要になった」として非推奨
進捗の指定 reportProgress: true で上りと下りの両方 reportUploadProgress と reportDownloadProgress に分離。HttpRequest.reportProgress は 22.0 で非推奨
JSONP withJsonpSupport() で利用可能 22.1 で非推奨。XSS の経路になるという理由で、将来の削除が予告されている
インターセプター内の signal 読み取り 呼び出した反応的な文脈に依存として記録される 22.1 からチェーン全体が untracked の中で走る

RxJS 自体の peer 範囲は ^6.5.3 || ^7.4.0 のままで、RxJS 8 は入っていない(レジストリの最新は 7.8.2)。購読の解除は 16 から takeUntilDestroyed() が使えるため、takeUntil(this.destroy$) を前提にした観点は本リストでは使っていない。


✅ 購読の生成と解除(01〜10)

この購読を止めるのは誰か、差分から言えるかを問う。

HttpClient の Observable は1回で complete するから解除は要らない、という説明はここで崩れる。complete はするが、応答が返る前に画面を離れれば、subscribe に渡した処理は残った購読の上で走る。

02 が実行時エラーになる仕組みは引数の既定値にある。takeUntilDestroyed(destroyRef?) は引数を省くと現在の DestroyRef を inject で取る。コンストラクタやフィールド初期化子の中なら解決できるが、メソッドの中やコールバックの中で呼ぶと注入コンテキストから外れており、DestroyRef を明示的に渡さなければ解決に失敗する。コンストラクタで DestroyRef を受けておき、以後はそれを渡す形にすれば位置に依存しない。

04 の位置が結果を変えるのは、Operator が順に適用されるためである。takeUntilDestroyed() は破棄時に complete を流すので、その下流にある Operator はすべて終了する。一方、takeUntilDestroyed() より上に置いた Operator が内部で新たな購読を張る場合、その購読は解除の対象に入らない。switchMap のあとに置いた takeUntilDestroyed() は内側の購読も含めて止めるが、前に置くと内側は止まらない。

09 の EmptyError が見つかりにくい理由は、空になる条件が入力依存であることにある。lastValueFrom は complete までの最後の値を返す契約なので、1件も流れずに complete した Observable では返す値がなく例外になる。filter を通した通信結果や、条件に合う要素がないときに EMPTY を返す分岐の先で起きる。既定値が欲しい場合は defaultValue を渡す。


✅ Observable の契約(11〜20)

この Observable は購読のたびに実行されるのか、共有されるのかを問う。

shareReplay() を付けておけばキャッシュになる、とは言えない。refCount を指定しないと、購読者が0になっても上流の購読が残る。

11 と 12 は同じ既定値の表と裏である。shareReplay() の refCount の既定は false なので、購読者が全員離れても上流への購読が残り、タイマーやポーリングは動き続ける。代わりに、次に来た購読者はバッファに残った値をすぐ受け取る。通信を1回に抑えたいなら refCount: false が目的に合い、画面を離れたら止めたいなら refCount: true が合う。再訪時に最新の値が要るかどうかで選び分ける話で、どちらが安全という順序はない。保持と解除の組み合わせはshareReplayをキャッシュとして使う実装をレビューで止めるにまとめてある。

15 が問題になる場面は、同期発行を前提にしていないコードが受け取ったときである。of(x) は購読と同時に値を出して complete する。subscribe の中で初期化処理を書いているコードに of 由来の Observable を渡すと、その処理がフィールドの初期化より先に走る。非同期を前提に組まれたコードは、同期発行のほうで壊れる。

16 の違いは評価のタイミングにある。of(this.value) は Observable を作った時点の値を固定する。defer(() => of(this.value)) は購読のたびに関数を呼ぶので、購読時点の値になる。再試行を挟む構成では前者が古い値を送り続ける。


✅ 高階 Operator と競合制御(21〜30)

同じ入力が連続したときに、前の処理をどうするか決めてあるかを問う。

mergeMap が一番速い、という理解はこの群を無効にする。順序も同時実行数も保証されないため、速いのではなく制御していないだけである。

22 が 22 で重くなった理由は、既定のバックエンドが変わったことにある。switchMap が行うのは内側の Observable の購読解除であり、実処理が止まるかは内側の実装次第である。XHR 既定の時代も abort() は呼ばれていたが、Fetch 既定になった今は AbortController の signal がリクエストに付くため、中断がサーバー側まで届く条件が整った。保存処理に switchMap を置くと、2回目の送信で1回目が中断され、成功 Action も失敗 Action も出ない。どこまでが本当に止まるのかはswitchMapが実際には何を止めないのかに書いてある。

28 の性質は combineLatest の定義そのものである。全ソースが少なくとも1回発行するまで、1件も出力しない。BehaviorSubject を混ぜた構成では初期値があるので気付かないが、Subject や HTTP を1本混ぜた時点で、その1本が返るまで画面が何も表示しない状態になる。初期値が要る側には startWith を置く。

29 の forkJoin は complete を待ち合わせの合図にしている。BehaviorSubject や interval を渡すと complete しないため、1件も出力されないまま購読が残る。store.select() も complete しないので、ここに渡すと同じ形になる。待ち合わせの契約はforkJoinが値を出さずに終わる条件にまとめてある。


✅ エラー処理と再試行(31〜40)

エラーが流れた後、このストリームは生き残るかを問う。

catchError を書けばエラーは処理される、という理解では位置を判断できない。どこに書くかで、ストリームが終了するか生き残るかが決まる。

31 の機構は、エラーが流れる向きから説明できる。catchError は上流から来たエラーを受け止めて別の Observable に差し替えるが、エラーを出した上流のソース自体は既に終了している。入力のストリーム(フォームの valueChanges や Action の列)の外側に catchError を置くと、1回の失敗でその入力そのものが終わり、以後の操作に反応しなくなる。内側の switchMap の中に置けば、終了するのは1回の通信だけである。NgRx Effect での現れ方はcatchErrorの置き場所がNgRx Effectを停止させる仕組みにある。

32 が見逃されやすいのは、型が通るからである。catchError(() => of(null)) を書くと戻り値の型は T | null になり、受け手は null を「まだ無い」と解釈する。失敗したのか空だったのかは型から区別できず、再試行の判断もできない。失敗を値として扱うなら、成功と失敗を区別できる形(判別可能な union)にする。

36 の3つの経路のうち、解除時が落としやすい。finalize は complete・error・unsubscribe のすべてで呼ばれるので、「通信が終わったらスピナーを消す」処理を置くと、画面を離れたときにも走る。走って困る処理(完了通知の表示など)が混ざっていないかを見る。


✅ 時間系 Operator(41〜50)

遅延と間引きが、ユーザーの操作感と通信量の両方から決まっているかを問う。

debounceTime を付ければ入力の連打は防げる、という理解は対象を取り違えている。間引かれるのは下流へ流れる値であり、入力欄の表示とは別の話である。

42 の既定の比較は === である。valueChanges はフォームの値をオブジェクトとして出すので、中身が同じでも毎回新しい参照になり、distinctUntilChanged() は1件も間引かない。比較関数を渡すか、map で比較したいプリミティブへ落としてから通す。

43 の順序で変わるのは通信の回数である。debounceTime(300), switchMap(fetch) なら、間引かれた入力では通信が起きない。switchMap(fetch), debounceTime(300) なら入力ごとに通信が走り、結果だけが間引かれる。後者でも画面の表示は正しくなるため、通信ログを見るまで差が現れない。

45 の2つは発行する値が違う。auditTime は期間の終わりに、その期間の最後の値を出す。sampleTime は一定間隔ごとに、その時点での最新の値を出し、期間内に値が来なければ何も出さない。前者は入力が止まったあとの1回を取る用途に、後者は継続的に変化する値を定期的に見る用途に合う。


✅ HttpClient と通信(51〜60)

Fetch 既定になった前提で、この通信の挙動が変わらないかを問う。

withFetch() を付けないと Fetch にならない、という前提が逆になっている。22 では FetchBackend が既定で、withFetch() のほうに「不要になった」という非推奨が付いた。

53 と 54 が対になる理由は、分離の仕方にある。reportProgress: true は上りと下りの両方の進捗イベントを有効にしていた。22.0 からは reportUploadProgress と reportDownloadProgress に分かれ、どちらを見るかを書く形になった。アップロードの進捗バーだけが要る画面で両方を有効にすると、受信側の進捗イベントも配信される。なお httpResource のオプションにある reportProgress は非推奨になっておらず、こちらは内部でダウンロード側の進捗として扱われる。progress signal で読めるのは下りの進捗である。

56 の上限は SSR でだけ既定で入る。FetchBackend が参照する上限の既定値は、サーバー実行時が 1 MiB、ブラウザ実行時が無制限である。終わらない応答ストリームでメモリが伸び続けるのを防ぐための既定なので、SSR で大きな応答を受ける経路があるなら上限のほうを調整する。この上限を決める注入トークンは公開 API の名前では提供されていないため、依存するより、応答を分割する設計に寄せるほうが安全である。

58 の誤りは戻り値を捨てる形で現れる。HttpParams は set や append のたびに新しいインスタンスを返し、呼ばれたインスタンス自身は変わらない。params.set('page', '1') と書いて戻り値を受け取らなければ、クエリは1つも付かない。コンパイルは通り、サーバー側のログを見るまで気付けない。


✅ インターセプター(61〜70)

インターセプターの順序と副作用が、1本のリクエストに対して予測できるかを問う。

インターセプターは独立して書ける、という前提では順序の指摘ができない。登録順に入れ子になり、レスポンスは逆順に通る。

63 の帰結は通信の本数に出る。インターセプターは受け取ったリクエストを next(req) へ渡して1本の Observable を返す契約になっており、next を2回呼んで両方を購読すれば通信が2回走る。merge や concat で2本を束ねた実装は型が通るので、サーバー側のログで二重の記録として見つかる。

66 の挙動は、インターセプターのチェーンが untracked の中で実行されることに由来する。反応的な文脈(effect や httpResource の読み込み)から出した通信では、インターセプターの中で読んだ signal が呼び出し元の依存に加わらない。この分離は、インターセプターの実装が呼び出し元の再実行の条件を変えてしまう事故を防ぐためのものである。逆に言えば、signal を読んで分岐するインターセプターは、その signal が変わっても通信が再実行されない。設定値を signal で持って分岐させる実装は、この前提で書かれているかを見る。

70 の制約は組み合わせの側にある。withRequestsMadeViaParent は、そのインジェクターで作った HttpClient のリクエストを親のハンドラへ渡す機能で、同じ provideHttpClient の呼び出しで withFetch や withXhr と併用できない。親のインターセプターを通したいのか、子で独自のバックエンドを使いたいのかのどちらかを選ぶことになる。


✅ Subject と状態共有(71〜80)

Subject を使う理由が、signal で置き換えられない性質にあるかを問う。

BehaviorSubject は状態を持つ標準的な方法である、という位置づけは 22 では残っていない。値を保持して同期的に読む用途は signal のほうが短く書ける。

71 を置き換えの候補として挙げられるのは、BehaviorSubject の2つの役割のうち片方だけが signal で足りる場合である。現在値を同期的に読み、変化で再描画したいだけなら signal が合う。発行の1件ごとに意味があり、同じ値の連続も区別したい場合は Subject が残る。signal はグラフの安定化のたびに評価されるので、1件ずつのイベント列としては使えない。この境界はtoObservableをイベント列として使う実装をレビューで止めるにまとめてある。

76 が静かに通る理由は、next が例外を投げないことにある。Subject はエラーまたは complete で終了し、以後の next は無視される。破棄済みのサービスに残ったハンドラから next を呼んでも、何も起きないまま処理が進む。「イベントを送ったのに届かない」形で現れ、呼び出し側のログだけを見ると送信は成功している。

80 を前提の共有として問うのは、エラーの扱いがチャネルの寿命を決めるからである。通知用の Subject にエラーを流すと、そのチャネル自体が終了して以後の通知が届かなくなる。失敗を通知したいなら、エラーとして流すのではなく、失敗を表す値を next で流す形にする。


✅ signal との境界と zoneless(81〜90)

RxJS と signal の変換点が1箇所に寄り、往復していないかを問う。

zoneless にすると RxJS が使えなくなる、という心配は当たらない。使えるが、購読の中で DOM や非 signal のフィールドを書き換える書き方が通らなくなる。

82 の筋道は変更検知の起点にある。zoneless では、再描画は signal の変化・テンプレートのイベント・markForCheck の呼び出しで起きる。subscribe の中で素のフィールドに代入しても、どの起点にも当たらないため、次に別の理由で再描画されるまで画面は古いままである。zone のある構成では偶然動いていたコードが、zoneless へ切り替えた時点で止まる。signal に書くか async パイプで受ける形にすれば、起点のほうに乗る。

81 の往復が問題になるのは、変換のたびに発行の粒度が変わるためである。toObservable の通知は内部で effect を経るので、同じ安定化期間の中で signal が3回変わっても、流れるのは最後の値だけである。signal から Observable へ、またその結果を toSignal で戻す構成では、途中の値が消えたことが読み取れない。変換は入口か出口のどちらか1箇所に置く。

87 の影響範囲は SSR に限る。pendingUntilEvent は、ストリームが最初の値を出すまでアプリを「保留中」として扱う Operator で、SSR ではその間レンダリングの完了が待たれる。クライアントだけで使えば実害はないが、SSR では最初の値が遅いストリームが全体の応答時間を決める。入れた理由が「初期表示にこの値が要る」であることを確かめる。


✅ テストと検証(91〜100)

時間とエラーの挙動が、実時間に依存せず検証されているかを問う。

setTimeout を使ったテストで十分である、という書き方はそのまま不安定さになる。待ち時間の仮定が実行環境の速度に依存する。

93 を明示的に求めるのは、正常系のテストでは差が出ないためである。入力を1件だけ流すテストは switchMap でも concatMap でも mergeMap でも通る。2件を近い時刻に流し、出力の件数と順序を見たときに初めて選択の違いが現れる。marble で入力を -a-b---- の形にすれば、どれを選んだかがテストに書かれた状態になる。

96 の verify() が拾うのは、テストが期待していない通信である。HttpTestingController は期待した通信を expectOne で取り出す形で使い、verify() は取り出されずに残った通信があれば失敗させる。呼ばないテストは、余分な通信が走っていても通る。インターセプターを足したときに増えた通信は、この経路で見つかる。

99 の fakeAsync が zoneless で噛み合わない理由は、依存している仕組みにある。fakeAsync と tick は zone がパッチしたタイマーを進める仕組みなので、zone を外した構成では、その前提が成り立たない。時間を進める必要があるテストは TestScheduler に寄せ、変更検知の待ち合わせには ApplicationRef.whenStable() を使う形にすると、どちらの構成でも同じテストが使える。


範囲の切れ目

signal と Resource の読み取り契約、ストアの設計(NgRx Store と SignalStore)、テンプレートの描画は、ここに含めていない。4系統はそれぞれ別の API 契約を持ち、1本に押し込めば1系統あたり十数項目にしかならないためである。signal と Resource はAngular 22のSignalと非同期状態をレビューする100観点、NgRx Store はNgRx StoreのAction・Reducer・Effectをレビューする100観点、SignalStore はNgRx SignalStoreの状態設計をレビューする100観点、コンポーネントとテンプレートはAngular 22のコンポーネント設計をレビュー視点で整理する100観点にある。個別論点の解説はng状態管理にまとめてある。