Angular 2026のRxJS:演算子・Subject・Signals相互運用

Angular 2026のRxJS:本番で使われる演算子、Subject、Signalsとの相互運用パターンを、面接で最も問われるポイントとあわせて習得します。

Angular 2026のRxJS 演算子・Subject・Signals相互運用

AngularにおけるRxJSは、Signalsがコンポーネントの同期的な状態追跡のあり方を刷新した2026年になっても、非同期プログラミングの屋台骨であり続けています。Observableに手を伸ばすべき場面、Signalのほうが適している場面、そしてその2つがどのように相互運用されるかを把握することは、いまやすべてのAngular開発者に求められる中核的なスキルです。本稿では、面接で自信を持って答えられる回答と、行き詰まってしまう回答とを分ける演算子・Subject・相互運用パターンを詳しく掘り下げます。

SignalsとObservablesを一言で

Signalsは常に現在値を持つ同期的な状態をモデル化し、Observablesは時間の経過とともに発生するイベントの非同期ストリームをモデル化します。Angular 20は両方を維持しており、@angular/core/rxjs-interopパッケージが両者を公式につなぐ橋渡しの役割を担います。

AngularのSignalsと並んでRxJSがいまなお重要な理由

Signalsの登場を受けて、多くのチームがRxJSはもう不要になるのではないかと問い始めました。メジャーバージョンを3つ重ねた現在の答えは「いいえ」です。Signalsは、いま値を持っている状態、たとえばカウンター、フォームのフィールド、選択中のタブなどを表現するのに秀でています。一方Observablesは、時間の経過とともに到着し、変換・キャンセル・合成を必要としうるイベント、たとえばHTTPレスポンス、WebSocketメッセージ、キーボード入力、ルーターイベント、タイマーなどをモデル化するのに秀でています。

Angular自身のAPIがこの棲み分けを可視化しています。HttpClientは依然としてObservableを返します。リアクティブフォームはvalueChangesstatusChangesをObservableとして公開します。ルーターイベントはObservableを通じてストリーミングされます。複数の非同期イベントが同じチャネルを流れるあらゆる場所で、RxJSの演算子は依然として最も表現力の高い手段であり続けています。公式のAngularリアクティビティガイドは、この2つのモデルを競合ではなく補完し合う関係として扱っており、RxJSのドキュメントも、少しでも複雑なデータフローには欠かせない参考資料であり続けています。

コードレビューで通用する実践的な指針はこうです。テンプレートで読み取るコンポーネントのローカルな状態にはSignalsを、演算子を必要とする非同期パイプラインにはObservablesを使う。相互運用レイヤーが両者を接続するため、どちらのモデルも壁になることはありません。

合成演算子はこの点をさらに裏づけます。複数の非同期ソースの協調、たとえばルートパラメータとユーザーの保存済みフィルタ、そしてリアルタイムの価格フィードをマージするといった処理こそ、combineLatestwithLatestFromforkJoinが設計上想定していたものです。Signalsは他のsignalから算出値を導けますが、「3つのソースすべてが少なくとも1つの値を発行したあとにのみ発行する」といった時間軸に基づく協調は表現できません。時間の経過にわたる宣言的な合成、これがRxJSがツールボックスに残り続ける普遍的な理由です。

すべてのAngular開発者が頼る中核的なRxJS演算子

演算子は、Observableを受け取って新しいObservableを返す純粋関数です。定番の面接シナリオは入力に応じた逐次検索(search-as-you-type)です。というのも、入力の間引き、重複のスキップ、古いリクエストのキャンセル、エラーからの復帰という4つの演算子を一度に扱うからです。

search.component.tstypescript
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  template: `<input [formControl]="query" placeholder="Search repositories" />`,
})
export class SearchComponent {
  private http = inject(HttpClient);
  query = new FormControl('');

  results$ = this.query.valueChanges.pipe(
    debounceTime(300),               // wait until typing pauses for 300ms
    distinctUntilChanged(),          // ignore emissions that repeat the last value
    switchMap((term) =>              // cancel the in-flight request, start a fresh one
      this.http.get(`/api/search?q=${term}`).pipe(
        catchError(() => of([]))     // recover from a failed request without killing the stream
      )
    )
  );
}

面接官が探りを入れてくる微妙なポイントは、なぜswitchMapが外側のレベルにあり、catchErrorがその内側にネストされているのかという点です。内側のHTTP ObservableにcatchErrorを置くと、失敗したリクエストは空配列を返し、外側のストリームは流れ続けます。catchErrorを外側のパイプに移すと、最初のエラーで検索全体が終了してしまい、以降のすべてのキーストロークが機能しなくなります。4つの平坦化演算子はそれぞれ異なる並行性の問いに答えるものであり、誤った演算子を選ぶことはAngularアプリで最も多い競合状態の原因の1つです。

平坦化演算子の選び方

switchMapは直前の内側リクエストをキャンセルし、最新のものだけを残します(タイプアヘッド検索)。mergeMapはすべての内側リクエストを並行実行します(独立した並列アップロード)。concatMapはリクエストを厳密な順序でキューに並べます(重なってはならない逐次書き込み)。exhaustMapは現在の処理が終わるまで新しい入力を無視します(二重クリックされた送信ボタンの抑止)。

この選択を正しく行うことが、不安定な機能を予測可能な機能へと変えます。SharpSkillのRxJS演算子モジュールは、各演算子が防ぐ競合状態そのものを再現する段階別の設問で、これらの違いを徹底的に訓練します。

Subjectとマルチキャスト:BehaviorSubject、ReplaySubject、Subject

素のObservableはユニキャストで、サブスクライバーごとに新しい実行がトリガーされます。SubjectはObservableでもありObserverでもあるため、マルチキャストになります。この性質により、Subjectはサービス内に軽量な状態ストアやイベントバスを構築する標準的な手段となっています。

3つのバリアントは、遅れて購読したサブスクライバーに何をリプレイするかで異なります。素のSubjectは過去の値を一切発行しないため、サブスクライバーは購読後に到着した値だけを見ます。BehaviorSubjectは現在値を保持して即座にリプレイするため、初期値が必要です。ReplaySubjectは設定可能な数の過去の発行をバッファリングし、それらすべてをリプレイします。

cart.service.tstypescript
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class CartService {
  // BehaviorSubject holds the latest value and replays it to new subscribers
  private readonly itemsSubject = new BehaviorSubject<string[]>([]);

  // expose a read-only stream, never the writable Subject itself
  readonly items$ = this.itemsSubject.asObservable();

  add(item: string): void {
    const current = this.itemsSubject.value;    // synchronous access to the last value
    this.itemsSubject.next([...current, item]); // emit the next immutable state
  }
}

ここでは2つの習慣が重要です。第一に、SubjectそのものではなくasObservable()を公開することで、外部コードがnext()を呼び出して裏口から状態を変更するのを防ぎます。第二に、その場で変更するのではなく新しい配列参照を発行することで、変更検知と等価性チェックを予測可能に保ちます。このパターンはSignalsより前から存在し、複数の無関係なコンポーネントが消費する共有・注入可能な状態にはいまも十分に機能します。

とはいえ、テンプレートで読み取る単一の現在値を保持するためだけに使われるBehaviorSubjectは、いまや書き込み可能なSignalとして表現したほうがよい場合が多く、そうすれば購読とasObservable()という定型処理が不要になります。ストアが発行値の変換・デバウンス・他ストリームとの合成も必要とする場合には、これらの演算子にSignalの直接的な等価物が存在しないため、Subjectのほうが依然として強力な選択肢です。あらゆる場面で一方をデフォルトにするのではなく、ユースケースごとに両者を使い分けられることが、面接官が評価する習熟度の証です。

toSignalとtoObservableによるRxJSとSignalsの相互運用

相互運用パッケージは2つの主役級の関数を提供します。toSignalはObservableを、テンプレートが直接消費できる読み取り専用のSignalに変換し、購読のライフサイクルを自動的に管理します。toObservableはその逆で、SignalをObservableに変換し、RxJSの演算子がその変化を処理できるようにします。

dashboard.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';

@Component({ selector: 'app-dashboard', template: `{{ user()?.name }}` })
export class DashboardComponent {
  private http = inject(HttpClient);

  // a Signal drives the query; toObservable turns each change into a stream event
  readonly userId = signal(1);
  private readonly user$ = toObservable(this.userId).pipe(
    switchMap((id) => this.http.get<{ name: string }>(`/api/users/${id}`))
  );

  // toSignal subscribes immediately and unsubscribes on destroy, no manual teardown
  readonly user = toSignal(this.user$, { initialValue: null });
}

initialValueオプションは注目に値します。これがないと、toSignalはまだ何も発行されていないため、値またはundefinedという型のSignalを返します。initialValue: nullを渡すと、テンプレートには明確な初期状態とよりきれいな型が与えられます。BehaviorSubjectのような同期的なソースでは、requireSync: trueによって最初の読み取り時に値が利用可能であることが保証され、型からundefinedが完全に取り除かれます。この双方向の橋渡しこそが、既存のObservableパイプラインをすべて書き換えることなく、コードベースが段階的にSignalsを採用することを可能にします。関連するAngular Signals面接モジュールはリアクティビティモデルを深く扱い、Angular 18のSignals機能概要はこれらのプリミティブがどう進化してきたかを追っています。

Angularの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

rxResourceでObservableをsignalリソースへ橋渡しする

Angular 20は、Observableからの非同期データをsignalベースの状態へ読み込む宣言的な手段としてrxResourceを推奨しています。読み込み中・エラー・解決済みの状態をsignalとして追跡するため、HTTP呼び出しがかつて必要としていた手作業の購読管理の大半を取り除きます。

products.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({ selector: 'app-products', template: `` })
export class ProductsComponent {
  private http = inject(HttpClient);
  readonly category = signal('laptops');

  // Angular 20 renamed the fields: request -> params and loader -> stream
  readonly products = rxResource({
    params: () => ({ category: this.category() }),
    stream: ({ params }) =>
      this.http.get<Product[]>(`/api/products?category=${params.category}`),
  });
  // products.value(), products.isLoading() and products.error() are all signals
}

Angular 19のrequest/loaderからAngular 20のparams/streamへのリネームはよくある落とし穴なので、現行のフィールド名を挙げられることは面接で最新の知識を持っている証になります。category signalが変化すると、rxResourceは直前のリクエストをキャンセルして新しいストリームを開始し、switchMapのセマンティクスを無償で提供します。rxResource APIリファレンスは状態のライフサイクル全体を文書化しています。

takeUntilDestroyedでメモリリークを防ぐ

コンポーネントより長く生き残る手作業のsubscribe()はすべてメモリをリークし、破棄されたビューに対してコールバックを発火させる可能性があります。従来の解決策は、ngOnDestroyで完了させるSubjectを用いたtakeUntil(this.destroy$)パターンでした。Angularはこの定型処理を、購読を現在のインジェクションコンテキストまたは明示的なDestroyRefに結びつける演算子takeUntilDestroyedで置き換えました。

live-feed.component.tstypescript
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({ selector: 'app-live-feed', template: `` })
export class LiveFeedComponent {
  private destroyRef = inject(DestroyRef);

  constructor() {
    interval(1000)
      // completes the subscription automatically when the component is destroyed
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((tick) => console.log('tick', tick));
  }
}

コンストラクタまたはフィールド初期化子の内部で呼び出された場合、takeUntilDestroyed()は周囲のDestroyRefを読み取るため引数を必要としません。それ以外の場所、たとえばメソッドの内部で呼び出された場合は、上記のように明示的なDestroyRefを渡す必要があります。toSignalrxResourceを優先すれば手作業の購読を完全に回避でき、これがリークを避ける最もきれいな方法です。素の購読が本当に必要な場合には、takeUntilDestroyedが正しいガードとなります。

インジェクションコンテキストの落とし穴

インジェクションコンテキストの外で引数なしにtakeUntilDestroyed()を呼び出すと、実行時エラーを投げます。イベントハンドラ、setTimeout、あるいはRxJSのコールバックの内部での購読はそのコンテキストの外にあるため、DestroyRefは(通常は注入されたフィールドとして)より早い段階で捕捉し、明示的に渡さなければなりません。これを忘れることが、「takeUntilDestroyed() can only be used within an injection context」というエラーの最も頻繁な原因です。

練習しておく価値のあるAngular RxJS面接質問

面接官は同じ高シグナルの話題を繰り返し取り上げがちです。これらに端的に答えられることが、RxJSを実際に使ってきた候補者と、読んだだけの候補者とを分けます。

  • ホットObservableとコールドObservable。 コールドObservableは購読ごとにプロデューサーを開始するため、各サブスクライバーは独立した実行(新しいHTTPリクエスト)を得ます。ホットObservableは1つのプロデューサーをサブスクライバー間で共有するもので、Subjectやshare()が生み出すのがこれです。
  • switchMap対mergeMap対concatMap対exhaustMap。 switchMapは直前の内側Observableをキャンセルします(検索に最適)。mergeMapはすべての内側Observableを並行実行します。concatMapはそれらを順序どおりキューに並べます。exhaustMapは1つが実行中の間、新しい入力を無視します(フォームの二重送信の防止に最適)。
  • メモリリークをどう防ぐか。 toSignalrxResource、またはasyncパイプを優先しましょう。いずれも自動的に購読解除します。手作業の購読にはtakeUntilDestroyedを使います。
  • ObservableをSignalに変換すべきタイミング。 テンプレートが値を同期的に必要とし、かつソースが非同期である場合にはtoSignalに手を伸ばし、asyncパイプなしで変更検知が最新値を読み取れるようにします。

時間のプレッシャーの中でこれらの段階別バージョンに取り組むことが、身につける最速の方法です。Angularテクノロジーハブは、これらの質問に直接対応するRxJS・Signals・変更検知の各モジュールをまとめています。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

RxJSとSignalsは、現代のAngularスタックにおいてライバルではなくパートナーです。次のプロジェクトや面接に持ち込む価値のある要点は次のとおりです。

  • 同期的なコンポーネントの状態にはSignalsを、演算子を必要とする非同期イベントパイプラインにはObservablesを使う。
  • 古い処理をキャンセルするにはswitchMap、重複を抑止するにはexhaustMapに手を伸ばし、catchErrorは内側のObservableに置いて外側のストリームが失敗を生き延びるようにする。
  • Subjectの状態はasObservable()を通じて公開し、イミュータブルな値を発行して変更検知を予測可能に保つ。
  • 2つのモデルはtoSignaltoObservableで橋渡しし、きれいな型のためにinitialValueまたはrequireSyncを渡す。
  • Angular 20ではrxResourceで非同期データを宣言的に読み込み、paramsstreamというフィールド名を忘れない。
  • 残りのすべての手作業の購読はtakeUntilDestroyedでガードし、リークを排除する。

タグ

#angular
#rxjs
#signals
#observables
#typescript
#deep-dive

共有

関連記事