よくある間違いと対処法
このページでは、TypeScriptでRxJSを使う際によく見られる15のアンチパターンと、それぞれの解決策を詳しく解説します。
目次
- Subject の外部公開
- ネストした subscribe(コールバック地獄)
- unsubscribe 忘れ(メモリリーク)
- shareReplay の誤用
- map での副作用
- Cold/Hot Observable の違いの無視
- Promise と Observable の不適切な混在
- バックプレッシャーの無視
- エラーの握りつぶし
- DOM イベントサブスクリプションのリーク
- 型安全性の欠如(any の多用)
- 不適切なオペレーター選択
- 過度な複雑化
- subscribe 内での状態変更
- テストの欠如
1. Subject の外部公開
問題
Subject をそのまま公開すると、外部から next() を呼ばれてしまい、状態管理が予測不可能になります。
❌ 悪い例
import { Subject } from 'rxjs';
// Subject をそのまま export
export const cartChanged$ = new Subject<void>();
// 別のファイルから誰でも next() を呼べてしまう
cartChanged$.next(); // 予期しないタイミングで呼ばれる可能性✅ 良い例
import { BehaviorSubject, Observable } from 'rxjs';
class CartStore {
private readonly _items$ = new BehaviorSubject<string[]>([]);
// 読み取り専用の Observable として公開
readonly items$: Observable<string[]> = this._items$.asObservable();
// 状態変更は専用メソッドで制御
add(item: string): void {
this._items$.next([...this._items$.value, item]);
}
remove(item: string): void {
this._items$.next(
this._items$.value.filter(i => i !== item)
);
}
}
export const cartStore = new CartStore();解説
asObservable()で読み取り専用のObservableに変換- 状態変更は専用メソッド経由でのみ可能にする
- 変更のトレーサビリティが向上し、デバッグが容易に
2. ネストした subscribe(コールバック地獄)
問題
subscribe の中でさらに subscribe を呼ぶと、コールバック地獄に陥り、エラー処理やキャンセル処理が複雑になります。
❌ 悪い例
import { of } from 'rxjs';
// API 呼び出しのシミュレーション
function apiA() {
return of({ id: 1 });
}
function apiB(id: number) {
return of({ id, token: 'abc123' });
}
function apiC(token: string) {
return of({ success: true });
}
// ネストした subscribe
apiA().subscribe(a => {
apiB(a.id).subscribe(b => {
apiC(b.token).subscribe(result => {
console.log('done', result);
});
});
});✅ 良い例
import { of } from 'rxjs';
import { switchMap } from 'rxjs';
function apiA() {
return of({ id: 1 });
}
function apiB(id: number) {
return of({ id, token: 'abc123' });
}
function apiC(token: string) {
return of({ success: true });
};
// 高階オペレーターを使ってフラット化
apiA().pipe(
switchMap(a => apiB(a.id)),
switchMap(b => apiC(b.token))
).subscribe(result => {
console.log('done', result);
});解説
switchMap、mergeMap、concatMapなどの高階オペレーターを使用- エラー処理が一箇所で可能
- 購読解除も一度で済む
- コードの可読性が向上
3. unsubscribe 忘れ(メモリリーク)
問題
無限ストリーム(イベントリスナーなど)の購読を解除しないと、メモリリークが発生します。
❌ 悪い例
import { fromEvent } from 'rxjs';
// コンポーネントの初期化時
function setupResizeHandler() {
fromEvent(window, 'resize').subscribe(() => {
console.log('resized');
});
// 購読を解除していない!
}
// コンポーネントが破棄されてもイベントリスナーが残り続ける✅ 良い例
import { fromEvent, Subject } from 'rxjs';
import { takeUntil, finalize } from 'rxjs';
class MyComponent {
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
fromEvent(window, 'resize').pipe(
takeUntil(this.destroy$),
finalize(() => console.log('cleanup'))
).subscribe(() => {
console.log('resized');
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}✅ 別の良い例(Subscription を使う方法)
import { fromEvent, Subscription } from 'rxjs';
class MyComponent {
private subscription = new Subscription();
ngOnInit(): void {
this.subscription.add(
fromEvent(window, 'resize').subscribe(() => {
console.log('resized');
})
);
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}解説
takeUntilパターンが推奨される(宣言的で明確)Subscriptionを使った手動管理も有効- コンポーネント破棄時に必ず購読解除を実行
4. shareReplay の誤用
問題
shareReplay の動作を理解せずに使うと、古いデータが再生されたり、メモリリークが発生したりします。
❌ 悪い例
import { interval } from 'rxjs';
import { shareReplay, take } from 'rxjs';
// バッファサイズを無制限にしてしまう
const shared$ = interval(1000).pipe(
shareReplay() // デフォルトは無制限バッファ
);
// 購読者がいなくなっても値がメモリに残り続ける✅ 良い例
import { interval } from 'rxjs';
import { shareReplay, take } from 'rxjs';
// バッファサイズと参照カウントを明示的に指定
const shared$ = interval(1000).pipe(
take(10),
shareReplay({
bufferSize: 1,
refCount: true // 購読者がいなくなったらリソース解放
})
);解説
bufferSizeを明示的に指定(通常は 1)refCount: trueで購読者がいなくなったら自動解放- HTTP リクエストなど、完了するストリームでは
shareReplay({ bufferSize: 1, refCount: true })が安全
5. map での副作用
問題
map オペレーター内で状態を変更すると、予測不可能な動作を引き起こします。
❌ 悪い例
import { of } from 'rxjs';
import { map } from 'rxjs';
let counter = 0;
const source$ = of(1, 2, 3).pipe(
map(value => {
counter++; // 副作用!
return value * 2;
})
);
source$.subscribe(console.log);
source$.subscribe(console.log); // counter が予期せず増加✅ 良い例
import { of } from 'rxjs';
import { map, tap, scan } from 'rxjs';
// 純粋な変換のみ
const source$ = of(1, 2, 3).pipe(
map(value => value * 2)
);
// 副作用は tap で分離
const withLogging$ = source$.pipe(
tap(value => console.log('Processing:', value))
);
// 状態の蓄積は scan を使う
const withCounter$ = of(1, 2, 3).pipe(
scan((acc, value) => ({ count: acc.count + 1, value }), { count: 0, value: 0 })
);解説
mapは純粋関数として使用- 副作用(ログ、API 呼び出しなど)は
tapに分離 - 状態の蓄積は
scanやreduceを使用
6. Cold/Hot Observable の違いの無視
問題
Observable が Cold か Hot かを理解せずに使うと、重複実行や予期しない動作を引き起こします。
❌ 悪い例
import { ajax } from 'rxjs/ajax';
// Cold Observable - 購読ごとに HTTP リクエストが実行される
const data$ = ajax.getJSON('https://api.example.com/data');
data$.subscribe(console.log); // リクエスト 1
data$.subscribe(console.log); // リクエスト 2(無駄な重複)✅ 良い例
import { ajax } from 'rxjs/ajax';
import { shareReplay } from 'rxjs';
// Hot Observable に変換して共有
const data$ = ajax.getJSON('https://api.example.com/data').pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
data$.subscribe(console.log); // リクエスト 1
data$.subscribe(console.log); // キャッシュされた結果を使用解説
- Cold Observable: 購読ごとに実行される(
of,from,fromEvent,ajaxなど) - Hot Observable: 購読に関係なく実行される(
Subject, マルチキャスト化したObservable など) share/shareReplayで Cold を Hot に変換可能
7. Promise と Observable の不適切な混在
問題
Promise と Observable を適切に変換せずに混在させると、エラーハンドリングやキャンセル処理が不完全になります。
❌ 悪い例
import { from } from 'rxjs';
async function fetchData(): Promise<string> {
return 'data';
}
// Promise をそのまま使っている
from(fetchData()).subscribe(data => {
fetchData().then(moreData => { // ネストした Promise
console.log(data, moreData);
});
});✅ 良い例
import { from } from 'rxjs';
import { switchMap } from 'rxjs';
async function fetchData(): Promise<string> {
return 'data';
}
// Promise を Observable に変換して統一
from(fetchData()).pipe(
switchMap(() => from(fetchData()))
).subscribe(moreData => {
console.log(moreData);
});解説
fromで Promise を Observable に変換- Observable パイプライン内で統一的に処理
- エラーハンドリングとキャンセルが容易に
8. バックプレッシャーの無視
問題
高頻度で発生するイベントを制御せずに処理すると、パフォーマンスが低下します。
❌ 悪い例
import { fromEvent } from 'rxjs';
// 入力イベントをそのまま処理
fromEvent(document.getElementById('search'), 'input').subscribe(event => {
// 入力のたびに API 呼び出し(過負荷)
searchAPI((event.target as HTMLInputElement).value);
});
function searchAPI(query: string): void {
console.log('Searching for:', query);
}✅ 良い例
import { fromEvent } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs';
// デバウンスとキャンセルを適用
fromEvent(document.getElementById('search'), 'input').pipe(
map(event => (event.target as HTMLInputElement).value),
debounceTime(300), // 300ms 待機
distinctUntilChanged(), // 値が変わった時のみ
switchMap(query => searchAPI(query)) // 古いリクエストはキャンセル
).subscribe(results => {
console.log('Results:', results);
});解説
debounceTimeで一定時間待機throttleTimeで最大頻度を制限distinctUntilChangedで重複を除外switchMapで古いリクエストをキャンセル
9. エラーの握りつぶし
問題
エラーを適切に処理しないと、デバッグが困難になり、ユーザー体験が低下します。
❌ 悪い例
import { ajax } from 'rxjs/ajax';
import { catchError } from 'rxjs';
import { of } from 'rxjs';
// エラーを無視
ajax.getJSON('https://api.example.com/data').pipe(
catchError(() => of(null)) // エラー情報が失われる
).subscribe(data => {
console.log(data); // null が来ても原因不明
});✅ 良い例
import { ajax } from 'rxjs/ajax';
import { catchError } from 'rxjs';
import { of } from 'rxjs';
interface ApiResponse {
data: unknown;
error?: string;
}
ajax.getJSON<ApiResponse>('https://api.example.com/data').pipe(
catchError(error => {
console.error('API Error:', error);
// ユーザーに通知
showErrorToast('データの取得に失敗しました');
// エラー情報を含む代替値を返す
return of({ data: null, error: error.message } as ApiResponse);
})
).subscribe((response) => {
if (response.error) {
console.log('Fallback mode due to:', response.error);
}
});
function showErrorToast(message: string): void {
console.log('Toast:', message);
}解説
- エラーをログに記録
- ユーザーにフィードバックを提供
- エラー情報を含む代替値を返す
- リトライ戦略を検討(
retry,retryWhen)
10. DOM イベントサブスクリプションのリーク
問題
DOM イベントリスナーを適切に解放しないと、メモリリークが発生します。
❌ 悪い例
import { fromEvent } from 'rxjs';
class Widget {
private button: HTMLButtonElement;
constructor() {
this.button = document.createElement('button');
// イベントリスナーを登録
fromEvent(this.button, 'click').subscribe(() => {
console.log('clicked');
});
// 購読解除していない
}
destroy(): void {
this.button.remove();
// リスナーが残ったまま
}
}✅ 良い例
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs';
class Widget {
private button: HTMLButtonElement;
private readonly destroy$ = new Subject<void>();
constructor() {
this.button = document.createElement('button');
fromEvent(this.button, 'click').pipe(
takeUntil(this.destroy$)
).subscribe(() => {
console.log('clicked');
});
}
destroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.button.remove();
}
}解説
takeUntilパターンで確実に購読解除- コンポーネント破棄時に
destroy$を発火 - DOM 要素削除前にリスナーを解放
11. 型安全性の欠如(any の多用)
問題
any を多用すると、TypeScript の型チェックが無効化され、実行時エラーが発生しやすくなります。
❌ 悪い例
import { Observable } from 'rxjs';
import { map } from 'rxjs';
function fetchUser(): Observable<any> {
return new Observable(subscriber => {
subscriber.next({ name: 'John', age: 30 });
});
}
// 型チェックが効かない
fetchUser().pipe(
map(user => user.naem) // タイポ!実行時まで気づかない
).subscribe(console.log);✅ 良い例
import { Observable } from 'rxjs';
import { map } from 'rxjs';
interface User {
name: string;
age: number;
}
function fetchUser(): Observable<User> {
return new Observable<User>(subscriber => {
subscriber.next({ name: 'John', age: 30 });
});
}
// 型チェックが効く
fetchUser().pipe(
map(user => user.name) // コンパイル時にエラー検出
).subscribe(console.log);解説
- インターフェースや型エイリアスを定義
Observable<T>の型パラメータを明示- TypeScript の型推論を最大限活用
12. 不適切なオペレーター選択
問題
目的に合わないオペレーターを使うと、非効率だったり予期しない動作を引き起こします。
❌ 悪い例
import { fromEvent } from 'rxjs';
import { mergeMap } from 'rxjs';
import { ajax } from 'rxjs/ajax';
// ボタンクリックごとに検索(古いリクエストがキャンセルされない)
fromEvent(document.getElementById('search-btn'), 'click').pipe(
mergeMap(() => ajax.getJSON('https://api.example.com/search'))
).subscribe(console.log);✅ 良い例
import { fromEvent } from 'rxjs';
import { switchMap } from 'rxjs';
import { ajax } from 'rxjs/ajax';
// 最新のリクエストのみを処理(古いリクエストは自動キャンセル)
fromEvent(document.getElementById('search-btn'), 'click').pipe(
switchMap(() => ajax.getJSON('https://api.example.com/search'))
).subscribe(console.log);主要な高階オペレーターの使い分け
| オペレーター | 用途 |
|---|---|
switchMap | 最新のストリームのみ処理(検索、オートコンプリート) |
mergeMap | 並列処理(順序不問) |
concatMap | 順次処理(順序が重要) |
exhaustMap | 実行中は新しい入力を無視(ボタン連打防止) |
解説
- 各オペレーターの挙動を理解
- ユースケースに応じた適切な選択
- 詳細は 変換オペレーター を参照
13. 過度な複雑化
問題
シンプルに書ける処理を、RxJS で過度に複雑化してしまうケース。
❌ 悪い例
import { Observable, of } from 'rxjs';
import { map, mergeMap, toArray } from 'rxjs';
// 単純な配列変換を RxJS で複雑化
function doubleNumbers(numbers: number[]): Observable<number[]> {
return of(numbers).pipe(
mergeMap(arr => of(...arr)),
map(n => n * 2),
toArray()
);
}✅ 良い例
import { fromEvent } from 'rxjs';
import { map } from 'rxjs';
// 配列処理は普通の JavaScript で十分
function doubleNumbers(numbers: number[]): number[] {
return numbers.map(n => n * 2);
}
// RxJS は非同期・イベント駆動の処理に使う
const button = document.getElementById('calc-btn') as HTMLButtonElement;
const numbers = [1, 2, 3, 4, 5];
fromEvent(button, 'click').pipe(
map(() => doubleNumbers(numbers))
).subscribe(result => console.log(result));解説
- RxJS は非同期処理やイベントストリームに使う
- 同期的な配列処理は通常の JavaScript で十分
- 複雑さとメリットのバランスを考慮
14. subscribe 内での状態変更
問題
subscribe 内で直接状態を変更すると、テストが困難になり、バグの原因になります。
❌ 悪い例
import { interval } from 'rxjs';
class Counter {
count = 0;
start(): void {
interval(1000).subscribe(() => {
this.count++; // subscribe 内で状態変更
this.updateUI();
});
}
updateUI(): void {
console.log('Count:', this.count);
}
}✅ 良い例
import { interval, BehaviorSubject } from 'rxjs';
import { scan, tap } from 'rxjs';
class Counter {
private readonly count$ = new BehaviorSubject<number>(0);
start(): void {
interval(1000).pipe(
scan(acc => acc + 1, 0),
tap(count => this.count$.next(count))
).subscribe();
// UI は count$ を購読
this.count$.subscribe(count => this.updateUI(count));
}
updateUI(count: number): void {
console.log('Count:', count);
}
}解説
- 状態は
BehaviorSubjectやscanで管理 subscribeはトリガーとして使用- テスタブルでリアクティブな設計
15. テストの欠如
問題
RxJS のコードをテストせずに本番環境にデプロイすると、リグレッションが発生しやすくなります。
❌ 悪い例
import { interval } from 'rxjs';
import { map, filter } from 'rxjs';
// テストなしでデプロイ
export function getEvenNumbers() {
return interval(1000).pipe(
filter(n => n % 2 === 0),
map(n => n * 2)
);
}✅ 良い例
import { TestScheduler } from 'rxjs/testing';
import { getEvenNumbers } from './numbers';
describe('getEvenNumbers', () => {
let scheduler: TestScheduler;
beforeEach(() => {
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('should emit only even numbers doubled', () => {
scheduler.run(({ expectObservable }) => {
const expected = '1s 0 1s 4 1s 8';
expectObservable(getEvenNumbers()).toBe(expected);
});
});
});解説
TestSchedulerでマーブルテストを実施- 非同期処理を同期的にテスト可能
- 詳細は テスト手法 を参照
まとめ
これらの15のアンチパターンを理解し、避けることで、より堅牢で保守性の高いRxJSコードを書けるようになります。
参考文献
このアンチパターン集は、以下の信頼できるソースを参考に作成されています。
公式ドキュメント・リポジトリ
- RxJS 公式ドキュメント - オペレーターとAPIの公式リファレンス
- GitHub Issue #5931 - shareReplayのメモリリーク問題に関する議論
アンチパターンとベストプラクティス
- RxJS in Angular - Antipattern 1: Nested subscriptions - Thinktecture AG
- RxJS in Angular - Antipattern 2: Stateful Streams - Thinktecture AG
- RxJS Best Practices in Angular 16 (2025) - InfoQ (2025年5月)
- RxJS: Why memory leaks occur when using a Subject - Angular In Depth
- RxJS Antipatterns - Brian Love
追加リソース
- Learn RxJS - オペレーターとパターンの実践的ガイド
- RxJS Marbles - オペレーターの視覚的な理解
コードレビューに活用
自分のコードがアンチパターンに該当していないか確認しましょう。
👉 アンチパターン回避チェックリスト - 15の確認項目でコードを見直す
各チェック項目から、このページの対応するアンチパターンの詳細へ直接ジャンプできます。
次のステップ
これらのベストプラクティスを日々のコーディングに取り入れて、品質の高いRxJSコードを書いていきましょう!