RxJS のオペレーターとは、Observable(時間とともに値が流れてくるもの)を受け取り、変換・絞り込み・結合などをして新しい Observable を返す関数です。pipe() の中に並べて使い、Angular では HttpClient・フォーム・ルーターの値を扱う場面で登場します。 RxJS 7.8.2 の rxjs/operators には113個の関数がありますが、ふだん使うのは30個ほどで、迷いやすいのは switchMap・mergeMap・concatMap・exhaustMap の4つの使い分けです。

この記事では、よく使う35個を用途別の早見表にまとめ、すべて RxJS 7.8.2 で実際に値を流して出力を確かめました。4つの Map 系に同じ入力を流したときの出力順を時刻つきで比べ、検索ボックス・二重送信の防止・並列取得・catchError の置き場所の4つの使い方を、コピーして動かせるコードと実行結果で示します。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / RxJS 7.8.2。Angular のコンポーネント例は Angular 22.2.1(ng new の最小構成)+ Vitest 5.0.3 で実行しました。Node のスクリプトは npm i rxjs のあと node ファイル名.mjs で動きます。

RxJS オペレーター早見表(用途別35個)

「入力 → 出力」は実際に流して確かめた結果です。時間が関係する行では、次の2つを入力にしています。

  • a$:0ms・100ms・200ms に 1 2 3 を流す
  • b$:50ms・150ms に x y を流す

作る(作成関数)

名前 すること 例 → 出力
of 引数の値を順に流して完了 of(1, 2, 3) → 1 2 3
from 配列・Promise などを Observable にする from([10, 20]) → 10 20 / from(Promise.resolve('ok')) → "ok"
interval 一定間隔で 0, 1, 2… を流し続ける interval(50).pipe(take(3)) → 0 1 2
timer 指定時間後に1回(第2引数があれば以後くり返し) timer(50) → 0
fromEvent DOM などのイベントを Observable にする fromEvent(target, 'ping') → イベントのたびに流れる

変換する

名前 すること 例 → 出力
map 1件ずつ別の値に変える of(1,2,3).pipe(map(v => v * 10)) → 10 20 30
scan 累積した途中経過を毎回流す of(1,2,3).pipe(scan((s, v) => s + v, 0)) → 1 3 6
pairwise 直前の値と組にする of(1,2,3).pipe(pairwise()) → [1,2] [2,3]
toArray 完了時に全部を配列で1回流す of(1,2,3).pipe(toArray()) → [1,2,3]
tap 値を変えずに処理をはさむ(ログなど) of(1,2).pipe(tap(v => v * 100)) → 1 2

絞り込む・間引く

名前 すること 例 → 出力
filter 条件に合う値だけ通す of(1,2,3,4).pipe(filter(v => v % 2 === 0)) → 2 4
take 先頭から n 件で完了 of(1,2,3).pipe(take(2)) → 1 2
skip 先頭から n 件を飛ばす of(1,2,3).pipe(skip(1)) → 2 3
first 条件に合う最初の1件で完了 of(1,2,3).pipe(first(v => v > 1)) → 2
takeWhile 条件を満たす間だけ通す of(1,2,3,1).pipe(takeWhile(v => v < 3)) → 1 2
takeUntil 別の Observable が流れたら完了 interval(40).pipe(takeUntil(timer(100))) → 0 1
distinctUntilChanged 直前と同じ値は通さない of(1,1,2,2,1).pipe(distinctUntilChanged()) → 1 2 1
debounceTime 指定時間、次の値が来なかったら最後の値を通す a$.pipe(debounceTime(150)) → 3
throttleTime 1件通したら指定時間は通さない a$.pipe(throttleTime(150)) → 1 3

別の Observable につなぐ(Map 系の4つ)

内側の処理は「150ms 後に受け取った値をそのまま流す」です。

名前 新しい値が来たとき、処理中のものは 例 → 出力
mergeMap そのまま続け、並行して動かす a$.pipe(mergeMap(inner)) → 1 2 3
switchMap 打ち切って、新しいものに切り替える a$.pipe(switchMap(inner)) → 3
concatMap 終わるまで待たせ、順番に動かす a$.pipe(concatMap(inner)) → 1 2 3
exhaustMap 続ける。新しい値のほうを無視する a$.pipe(exhaustMap(inner)) → 1 3

組み合わせる

名前 すること 例 → 出力
combineLatest どれかが流れるたびに、各々の最新値の組を流す combineLatest([a$, b$]) → [1,"x"] [2,"x"] [2,"y"] [3,"y"]
forkJoin 全部が完了したら、各々の最後の値を1回流す forkJoin([a$, b$]) → [3,"y"]
zip 1番目同士、2番目同士…と組にする zip(a$, b$) → [1,"x"] [2,"y"]
merge 流れてきた順にそのまま混ぜる merge(a$, b$) → 1 "x" 2 "y" 3
concat 前が完了してから次を流す concat(a$, b$) → 1 2 3 "x" "y"
withLatestFrom 主の値が流れたとき、相手の最新値を添える a$.pipe(withLatestFrom(b$)) → [2,"x"] [3,"y"]
startWith 最初に初期値を流す of(1,2).pipe(startWith(0)) → 0 1 2

エラー・後始末・共有

of(1,2,3) の 2 でエラーを投げる map を前に置いています(何もしないと出力は「1 error:boom」)。

名前 すること 例 → 出力
catchError エラーを受けて、代わりの Observable に切り替える catchError(() => of(-1)) → 1 -1 完了
retry エラーのとき最初から購読し直す retry(2)(3回目で成功する場合)→ 1 1 1 2 3 完了
timeout 指定時間内に値が来なければエラー timer(200).pipe(timeout(100)) → error:Timeout has occurred
finalize 完了・エラー・購読解除のどれでも最後に1回呼ばれる of(1).pipe(finalize(fn)) → 1 完了のあと fn が呼ばれた
delay 流れる時刻を遅らせる of(1).pipe(delay(50)) → 1(50ms 後)
shareReplay 結果を覚えておき、あとから購読した人にも同じ値を渡す shareReplay(1) あり:元の処理は1回/なし:購読のたびに実行(2人で2回)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「RxJSオペレーター一覧|switchMapとmergeMapの違い」

オペレーターの基本:pipe に並べて上から順に通す

オペレーターは pipe() の中に並べます。値は上から順に通り、途中で止められた値は下へ流れません。早見表の a$・b$ を作って2つのオペレーターを試す完成形のコードです。

import { combineLatest, debounceTime, from, map, timer, zip } from 'rxjs';

// a$: 0ms・100ms・200ms に 1, 2, 3 を流す / b$: 50ms・150ms に 'x', 'y' を流す
const a$ = () => zip(from([1, 2, 3]), timer(0, 100)).pipe(map(([v]) => v));
const b$ = () => zip(from(['x', 'y']), timer(50, 100)).pipe(map(([v]) => v));

// 流れてきた値を1行にまとめて表示する
const show = (name, obs$) => new Promise((resolve) => {
  const out = [];
  obs$.subscribe({
    next: (v) => out.push(JSON.stringify(v)),
    error: (e) => { console.log(name, out.join(' '), 'error:' + e.message); resolve(); },
    complete: () => { console.log(name, out.join(' '), '完了'); resolve(); },
  });
});

await show('debounceTime(150):', a$().pipe(debounceTime(150)));
await show('combineLatest:', combineLatest([a$(), b$()]));
debounceTime(150): 3 完了
combineLatest: [1,"x"] [2,"x"] [2,"y"] [3,"y"] 完了

RxJS 7.8.2 では、作成関数もオペレーターも 'rxjs' からまとめて import できます(この記事のコードはすべてその形で実行しました)。'rxjs/operators' からの import でも同じ結果になります。

switchMap・mergeMap・concatMap・exhaustMap の違いを同じ入力で比べる

4つはどれも「流れてきた値を使って別の Observable(通信など)を始め、その結果を流す」オペレーターです。違いは、処理が終わる前に次の値が来たときの扱いだけです。

比較コード

0ms・100ms・200ms に 1 2 3 を流し、それぞれ 300ms かかる処理につなぎます。finalize で「終了(完了または打ち切り)」も記録します。

import { Subject, timer, map, switchMap, mergeMap, concatMap, exhaustMap, finalize } from 'rxjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 「番号 n の注文を取得する」300ms かかる処理。結果を1件流して完了する
const fetchOrder = (n) => timer(300).pipe(map(() => `結果${n}`));

async function run(name, flatten) {
  const start = Date.now();
  const t = () => String(Math.round((Date.now() - start) / 10) * 10).padStart(4) + 'ms';
  const clicks = new Subject();
  const lines = [];
  clicks.pipe(
    flatten((n) => {
      lines.push(`${t()}  開始 ${n}`);
      return fetchOrder(n).pipe(finalize(() => lines.push(`${t()}  終了 ${n}`)));
    }),
  ).subscribe((v) => lines.push(`${t()}  → ${v}`));
  // 0ms, 100ms, 200ms に 1, 2, 3 を流す
  clicks.next(1); await sleep(100); clicks.next(2); await sleep(100); clicks.next(3);
  await sleep(1200);
  console.log(`--- ${name} ---\n` + lines.join('\n'));
}
await run('mergeMap', mergeMap);
await run('switchMap', switchMap);
await run('concatMap', concatMap);
await run('exhaustMap', exhaustMap);

実行結果(時刻は 10ms 単位に丸めています。数十ミリ秒の揺れは実行のたびに変わります):

--- mergeMap ---
   0ms  開始 1
 100ms  開始 2
 200ms  開始 3
 300ms  → 結果1
 300ms  終了 1
 400ms  → 結果2
 400ms  終了 2
 500ms  → 結果3
 510ms  終了 3
--- switchMap ---
   0ms  開始 1
 100ms  終了 1
 100ms  開始 2
 200ms  終了 2
 200ms  開始 3
 500ms  → 結果3
 500ms  終了 3
--- concatMap ---
   0ms  開始 1
 300ms  → 結果1
 300ms  終了 1
 300ms  開始 2
 600ms  → 結果2
 600ms  終了 2
 600ms  開始 3
 900ms  → 結果3
 900ms  終了 3
--- exhaustMap ---
   0ms  開始 1
 300ms  → 結果1
 300ms  終了 1

結果の読み方

オペレーター 開始された処理 結果が出た順と時刻 起きたこと
mergeMap 1・2・3 すべて(同時に動く) 結果1(300ms)→ 結果2(400ms)→ 結果3(500ms) 来た分だけ並行して動かす
switchMap 1・2・3 すべて 結果3(500ms)だけ 2 が来た時点で 1 を、3 が来た時点で 2 を打ち切る(「終了 1」「終了 2」が結果なしで出ている)
concatMap 1・2・3 すべて(1つずつ) 結果1(300ms)→ 結果2(600ms)→ 結果3(900ms) 前が終わるまで待たせ、来た順に動かす
exhaustMap 1 だけ 結果1(300ms)だけ 処理中に来た 2 と 3 は始まりもしない

使い分けの判断表

やりたいこと 選ぶもの 理由
検索・絞り込み・画面の切り替え(最新の結果だけ欲しい) switchMap 前の通信を打ち切るので、遅れて届いた結果で表示が戻らない
保存・送信ボタンの連打を1回にまとめたい exhaustMap 処理中のクリックを無視する
順番を守って1件ずつ処理したい(更新 → 再取得など) concatMap 前が終わってから次を始める
互いに無関係な処理を全部、なるべく早く終えたい mergeMap 並行して動かす。第2引数で同時実行数を絞れる
迷ったとき 取得は switchMap、更新は concatMap から考える 打ち切ってよいか、順番が必要かで決まる

switchMap の「打ち切り」は、前の処理の購読をやめて結果を受け取らなくなる、という意味です。結果を必ず受け取りたい保存や削除には concatMap か exhaustMap が向いています。

検索ボックスを作る:debounceTime+distinctUntilChanged+switchMap

入力のたびに検索すると、リクエストが多くなり、結果が前後して表示されることもあります。次の3つを並べるのが定番です。

順番 オペレーター 役割
1 debounceTime(300) 入力が 300ms 止まるまで待つ(打っている途中は送らない)
2 distinctUntilChanged() 前回と同じ語なら送らない
3 switchMap(検索) 新しい検索を始めるとき、前の検索を打ち切る

Node で動かす

検索 API の代わりに、500ms 後に結果を返す関数を使います。

import { Subject, timer, debounceTime, distinctUntilChanged, switchMap, map, tap, finalize } from 'rxjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const start = Date.now();
const t = () => String(Math.round((Date.now() - start) / 10) * 10).padStart(4) + 'ms';

// 検索 API の代わり。500ms 後に結果を1件返す
let requests = 0;
const search = (word) => {
  requests++;
  console.log(`${t()}  リクエスト開始 "${word}"`);
  return timer(500).pipe(
    map(() => `"${word}" の検索結果`),
    finalize(() => console.log(`${t()}  リクエスト終了 "${word}"`)),
  );
};

const input$ = new Subject();
input$.pipe(
  tap((v) => console.log(`${t()}  入力 "${v}"`)),
  debounceTime(300),          // 300ms 入力が止まったら通す
  distinctUntilChanged(),     // 直前と同じ文字列なら通さない
  switchMap((word) => search(word)), // 新しい検索が来たら前の検索は打ち切る
).subscribe((result) => console.log(`${t()}  → 表示: ${result}`));

// 人が入力する様子を再現する
input$.next('a'); await sleep(100);
input$.next('an'); await sleep(100);
input$.next('ang');                 // ここで 300ms 止まる → 検索
await sleep(400);
input$.next('angu'); await sleep(100);
input$.next('ang');                 // 1文字消して元に戻した → 同じ語なので検索しない
await sleep(400);
input$.next('angular');             // 300ms 後に検索開始
await sleep(400);                   // "angular" の検索が始まって 100ms 後に
input$.next('rxjs');                // 次の語を入力 → 300ms 後に前の検索を打ち切って切り替え
await sleep(1000);
console.log(`入力 7 回 / リクエスト ${requests} 回`);

実行結果:

   0ms  入力 "a"
 110ms  入力 "an"
 210ms  入力 "ang"
 510ms  リクエスト開始 "ang"
 610ms  入力 "angu"
 710ms  入力 "ang"
1010ms  → 表示: "ang" の検索結果
1010ms  リクエスト終了 "ang"
1110ms  入力 "angular"
1410ms  リクエスト開始 "angular"
1510ms  入力 "rxjs"
1810ms  リクエスト終了 "angular"
1810ms  リクエスト開始 "rxjs"
2310ms  → 表示: "rxjs" の検索結果
2310ms  リクエスト終了 "rxjs"
入力 7 回 / リクエスト 3 回
  • 入力 7 回に対して、リクエストは 3 回でした。a・an は debounceTime が、戻ってきた ang は distinctUntilChanged が止めています。
  • angular の検索は、結果が返る前に rxjs の検索が始まったため、「リクエスト終了 "angular"」が表示なしで出ています(switchMap による打ち切り)。画面に出るのは最後の語の結果だけです。

Angular のコンポーネントで書く

同じ組み合わせを、FormControl の valueChanges と HttpClient で書いた例です。結果は toSignal() で signal にしてテンプレートに出しています。コンポーネントとテストを1つのファイルにまとめてあり、ng new で作ったプロジェクトの src/app/search.spec.ts に置いて ng test --watch=false --reporters=verbose で実行できます。

import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';

interface Article { id: number; title: string; }

@Component({
  selector: 'app-article-search',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="keyword" placeholder="記事を検索" />
    <ul>
      @for (a of results(); track a.id) {
        <li>{{ a.title }}</li>
      }
    </ul>`,
})
class ArticleSearch {
  private http = inject(HttpClient);
  keyword = new FormControl('', { nonNullable: true });

  results = toSignal(
    this.keyword.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((q) =>
        this.http.get<Article[]>('/api/articles', { params: { q } }).pipe(
          catchError(() => of([] as Article[])), // 内側で受けるので、次の入力も流れ続ける
        ),
      ),
    ),
    { initialValue: [] as Article[] },
  );
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('検索ボックス', async () => {
  TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
  const fixture = TestBed.createComponent(ArticleSearch);
  const http = TestBed.inject(HttpTestingController);
  const c = fixture.componentInstance;
  const list = () => Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('li')).map((li) => li.textContent);
  fixture.detectChanges();

  // 100ms おきに3回入力
  c.keyword.setValue('a');   await vi.advanceTimersByTimeAsync(100);
  c.keyword.setValue('an');  await vi.advanceTimersByTimeAsync(100);
  c.keyword.setValue('ang'); await vi.advanceTimersByTimeAsync(299);
  console.log('入力3回・最後の入力から 299ms: リクエスト数 =', http.match(() => true).length);
  await vi.advanceTimersByTimeAsync(1);
  const first = http.expectOne((r) => r.url === '/api/articles');
  console.log('300ms 経過: リクエスト先 =', first.request.urlWithParams);

  // 応答が返る前に次の語を入力
  c.keyword.setValue('angular'); await vi.advanceTimersByTimeAsync(300);
  console.log('次の語を入力後: 1件目は打ち切られたか =', first.cancelled);
  const second = http.expectOne((r) => r.url === '/api/articles');
  console.log('2件目のリクエスト先 =', second.request.urlWithParams);
  second.flush([{ id: 1, title: 'Angular 入門' }, { id: 2, title: 'Angular と RxJS' }]);
  fixture.detectChanges();
  console.log('画面の一覧 =', JSON.stringify(list()));

  // サーバーがエラーを返しても、次の検索は動く
  c.keyword.setValue('x'); await vi.advanceTimersByTimeAsync(300);
  http.expectOne((r) => r.url === '/api/articles').flush('error', { status: 500, statusText: 'Server Error' });
  fixture.detectChanges();
  console.log('500 のあとの一覧 =', JSON.stringify(list()));
  c.keyword.setValue('rxjs'); await vi.advanceTimersByTimeAsync(300);
  http.expectOne((r) => r.url === '/api/articles').flush([{ id: 3, title: 'RxJS 早見表' }]);
  fixture.detectChanges();
  console.log('エラー後の再検索の一覧 =', JSON.stringify(list()));
  http.verify();
});

実行結果:

入力3回・最後の入力から 299ms: リクエスト数 = 0
300ms 経過: リクエスト先 = /api/articles?q=ang
次の語を入力後: 1件目は打ち切られたか = true
2件目のリクエスト先 = /api/articles?q=angular
画面の一覧 = ["Angular 入門","Angular と RxJS"]
500 のあとの一覧 = []
エラー後の再検索の一覧 = ["RxJS 早見表"]

HttpClient のリクエストは、switchMap が切り替えた時点で実際にキャンセルされます(cancelled = true)。また、catchError を switchMap の内側に置いているので、サーバーが 500 を返したあとも次の検索が動いています。この「置き場所」は後の章で詳しく比べます。

二重送信を防ぐ:exhaustMap

保存ボタンを連打したときに、リクエストを1回にまとめる例です。300ms かかる保存処理に、50ms 間隔で3回、完了後にもう1回クリックを流します。

import { Subject, timer, exhaustMap, mergeMap, map } from 'rxjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function run(name, flatten) {
  const start = Date.now();
  const t = () => String(Math.round((Date.now() - start) / 10) * 10).padStart(4) + 'ms';
  let saved = 0;
  // 保存 API の代わり。300ms かかる
  const save = (n) => { console.log(`${t()}  保存リクエスト送信(${n}回目のクリック)`); return timer(300).pipe(map(() => ++saved)); };
  const click$ = new Subject();
  click$.pipe(flatten((n) => save(n))).subscribe((count) => console.log(`${t()}  → 保存完了(合計 ${count} 件)`));
  console.log(`--- ${name} ---`);
  click$.next(1); await sleep(50); click$.next(2); await sleep(50); click$.next(3); // 連打
  await sleep(500);
  click$.next(4);                                                                   // 完了後にもう一度
  await sleep(400);
}
await run('mergeMap(連打した分だけ送信)', mergeMap);
await run('exhaustMap(処理中のクリックは無視)', exhaustMap);

実行結果:

--- mergeMap(連打した分だけ送信) ---
   0ms  保存リクエスト送信(1回目のクリック)
  50ms  保存リクエスト送信(2回目のクリック)
 100ms  保存リクエスト送信(3回目のクリック)
 310ms  → 保存完了(合計 1 件)
 350ms  → 保存完了(合計 2 件)
 450ms  → 保存完了(合計 3 件)
 610ms  保存リクエスト送信(4回目のクリック)
 910ms  → 保存完了(合計 4 件)
--- exhaustMap(処理中のクリックは無視) ---
   0ms  保存リクエスト送信(1回目のクリック)
 300ms  → 保存完了(合計 1 件)
 600ms  保存リクエスト送信(4回目のクリック)
 900ms  → 保存完了(合計 2 件)

mergeMap では 4 件保存され、exhaustMap では 2 件(最初のクリックと、完了後のクリック)だけです。処理中の 2 回目・3 回目は送信されていません。完了後のクリックはふつうに受け付けるので、「送信中だけ無視する」という動きになります。ボタンを disabled にする表示上の対策と組み合わせると、見た目と動作の両方がそろいます。

複数の API を並列で取得する:forkJoin と mergeMap の同時実行数

import { forkJoin, from, timer, map, mergeMap, concatMap, toArray, tap } from 'rxjs';
const start0 = () => { const s = Date.now(); return () => String(Math.round((Date.now() - s) / 10) * 10).padStart(4) + 'ms'; };
const done = (obs$) => new Promise((resolve) => obs$.subscribe({ complete: resolve }));

// API の代わり。ms ミリ秒後に値を1件返す
const api = (name, ms, t) => timer(ms).pipe(map(() => name), tap(() => console.log(`${t()}  ${name} 取得`)));

let t = start0();
console.log('--- forkJoin: 3つを同時に取得し、全部そろったら1回だけ流す ---');
await done(forkJoin({
  user: api('会員', 300, t),
  orders: api('注文一覧', 200, t),
  notices: api('お知らせ', 100, t),
}).pipe(tap((v) => console.log(`${t()}  → ${JSON.stringify(v)}`))));

t = start0();
console.log('--- concatMap: 1件ずつ順番に取得 ---');
await done(from([1, 2, 3, 4, 5, 6]).pipe(
  concatMap((id) => api(`記事${id}`, 100, t)), toArray(),
  tap((v) => console.log(`${t()}  → ${JSON.stringify(v)}`))));

t = start0();
console.log('--- mergeMap(fn, 2): 同時に2件までで取得 ---');
await done(from([1, 2, 3, 4, 5, 6]).pipe(
  mergeMap((id) => api(`記事${id}`, 100, t), 2), toArray(),
  tap((v) => console.log(`${t()}  → ${JSON.stringify(v)}`))));

t = start0();
console.log('--- forkJoin: 1つでもエラーになると全体がエラー ---');
await new Promise((resolve) => forkJoin([
  api('会員', 100, t),
  timer(50).pipe(map(() => { throw new Error('注文一覧の取得でエラー'); })),
]).subscribe({ next: (v) => console.log(v), error: (e) => { console.log(`${t()}  error: ${e.message}`); resolve(); } }));

実行結果:

--- forkJoin: 3つを同時に取得し、全部そろったら1回だけ流す ---
 100ms  お知らせ 取得
 200ms  注文一覧 取得
 300ms  会員 取得
 300ms  → {"user":"会員","orders":"注文一覧","notices":"お知らせ"}
--- concatMap: 1件ずつ順番に取得 ---
 100ms  記事1 取得
 200ms  記事2 取得
 300ms  記事3 取得
 400ms  記事4 取得
 500ms  記事5 取得
 610ms  記事6 取得
 610ms  → ["記事1","記事2","記事3","記事4","記事5","記事6"]
--- mergeMap(fn, 2): 同時に2件までで取得 ---
 100ms  記事1 取得
 100ms  記事2 取得
 200ms  記事3 取得
 200ms  記事4 取得
 300ms  記事5 取得
 300ms  記事6 取得
 300ms  → ["記事1","記事2","記事3","記事4","記事5","記事6"]
--- forkJoin: 1つでもエラーになると全体がエラー ---
  50ms  error: 注文一覧の取得でエラー
やりたいこと 書き方 今回の結果
種類の違うデータを同時に取り、全部そろってから画面を作る forkJoin({ a: a$, b: b$ }) いちばん遅い 300ms の時点で、キーつきのオブジェクトが1回流れた
件数の多い取得を、順番に1件ずつ concatMap 6 件で約 600ms
件数の多い取得を、同時実行数を決めて mergeMap(fn, 2) 2 件ずつ進み、6 件で約 300ms
どれか1つでもエラーになったら forkJoin 全体がエラーになる 50ms の時点で error。個別に catchError を付けると、その分だけ代わりの値にできる

forkJoin は各 Observable が完了したときの最後の値を使うので、完了する Observable(HttpClient の通信など)と組み合わせます。流れ続けるものどうしを組み合わせるときは combineLatest を使います(早見表の「組み合わせる」を参照)。

catchError の置き場所で流れが止まるかどうかが変わる

catchError は「エラーを受けて、代わりの Observable に切り替える」オペレーターです。置く場所によって、そのあとの入力が流れ続けるかどうかが変わります。

import { Subject, of, throwError, switchMap, catchError } from 'rxjs';

// "ng" を渡したときだけエラーになる検索 API の代わり
const search = (word) => (word === 'ng' ? throwError(() => new Error('500 Server Error')) : of(`"${word}" の結果`));

function run(name, build) {
  console.log(`--- ${name} ---`);
  const input$ = new Subject();
  build(input$).subscribe({
    next: (v) => console.log(`  next: ${v}`),
    error: (e) => console.log(`  error: ${e.message}`),
    complete: () => console.log('  complete(これ以降の入力は流れない)'),
  });
  for (const word of ['a', 'ng', 'b', 'c']) {
    console.log(`入力 "${word}"`);
    input$.next(word);
  }
}

run('A. catchError なし', (input$) => input$.pipe(
  switchMap((w) => search(w)),
));

run('B. catchError を外側(switchMap の後ろ)に置く', (input$) => input$.pipe(
  switchMap((w) => search(w)),
  catchError((e) => of(`エラー表示: ${e.message}`)),
));

run('C. catchError を内側(switchMap の中)に置く', (input$) => input$.pipe(
  switchMap((w) => search(w).pipe(
    catchError((e) => of(`エラー表示: ${e.message}`)),
  )),
));

実行結果:

--- A. catchError なし ---
入力 "a"
  next: "a" の結果
入力 "ng"
  error: 500 Server Error
入力 "b"
入力 "c"
--- B. catchError を外側(switchMap の後ろ)に置く ---
入力 "a"
  next: "a" の結果
入力 "ng"
  next: エラー表示: 500 Server Error
  complete(これ以降の入力は流れない)
入力 "b"
入力 "c"
--- C. catchError を内側(switchMap の中)に置く ---
入力 "a"
  next: "a" の結果
入力 "ng"
  next: エラー表示: 500 Server Error
入力 "b"
  next: "b" の結果
入力 "c"
  next: "c" の結果
置き方 エラーのあとの動き 向いている場面
A. なし error が呼ばれ、以後の入力(b・c)は流れない エラーを呼び出し側に任せるとき
B. 外側(switchMap の後ろ) 代わりの値が1回流れたあと complete し、b・c は流れない 1回きりの処理(画面を開いたときの取得など)
C. 内側(switchMap の中) 代わりの値が流れ、b・c もそのまま流れる 検索ボックスやボタンなど、入力が続くもの

B で止まるのは、エラーが外側の流れ(入力の Observable)まで届き、catchError が流れ全体を代わりの Observable(of(...))に切り替えるからです。of(...) は値を1つ流して完了するので、元の入力にはもう戻りません。C では、エラーになった内側の Observable だけが置き換わり、外側の入力は生きたままです。「入力が続く流れでは、catchError は内側に置く」 と覚えておくと、検索が1回エラーになったあと動かなくなる現象を防げます。

RxJS 7 での書き方のメモ

手元の RxJS 7.8.2 の型定義には、次の案内(@deprecated)が書かれています。新しく書くコードでは右側の書き方を選ぶと、次の版に上げるときの手間が減ります。

以前からの書き方 型定義の案内 これからの書き方
オペレーター版の combineLatest・merge・concat・zip・race(pipe の中で使うほう) combineLatestWith などに置き換え。v8 で削除予定 combineLatestWith・mergeWith・concatWith・zipWith・raceWith。または作成関数の combineLatest([...]) など
toPromise() firstValueFrom・lastValueFrom に置き換え。v8 で削除予定 await firstValueFrom(obs$)
subscribe(next, error, complete) と引数を並べる observer 引数を使う。v8 で削除予定 subscribe({ next, error, complete })
Map 系の resultSelector 引数 v8 で削除予定。内側で map を使う switchMap(v => inner$.pipe(map(...)))
retryWhen retry の delay オプションを使う retry({ count, delay })

combineLatestWith と retry({ count: 2, delay: 20 }) は 7.8.2 で動くことを確かめています。この記事で使った作成関数の combineLatest([a$, b$])・merge(a$, b$)・concat(a$, b$)・zip(a$, b$)・forkJoin(...) は、案内の対象ではなく、そのまま使えます。

やりたいこと別の逆引き表

やりたいこと 使うオペレーター
レスポンスから必要な項目だけ取り出す map
入力が落ち着いてから処理する debounceTime
スクロールや連続クリックを一定間隔に間引く throttleTime
同じ値の連続を1回にする distinctUntilChanged
ID が変わるたびに詳細を取り直す switchMap
連打しても1回だけ送信する exhaustMap
複数の更新を順番に実行する concatMap
複数の API の結果がそろってから表示する forkJoin
2つの条件(検索語と並び順など)のどちらが変わっても再計算する combineLatest
ボタンを押した時点のフォームの値を使う withLatestFrom
最初に空の一覧や読み込み中の値を出す startWith
合計やカウンターなど、積み上げた値を出す scan
通信エラーのとき代わりの値を出す catchError(入力が続く流れでは内側に置く)
一時的なエラーをやり直す retry
読み込み中の表示を必ず消す finalize
同じ通信を複数の場所で使い回す shareReplay(1)
途中の値をログに出す tap
コンポーネントの破棄に合わせて購読を止める takeUntil / takeUntilDestroyed

よくある質問

Q. map と switchMap はどう違いますか? A. map は値を別の値に変えます。switchMap は値から別の Observable(通信など)を始めて、その結果を流します。map の中で Observable を返すと、流れてくる値そのものが Observable になります(of(1).pipe(map(v => of(v * 10))) で確かめると、受け取った値は Observable でした)。そのため、通信につなぐときは Map 系の4つのどれかを使います。

Q. switchMap と mergeMap のどちらを使えばよいですか? A. 「前の処理の結果はもう要らない」なら switchMap、「全部の結果が必要」なら mergeMap です。検索や画面切り替えは前者、互いに無関係な複数の取得は後者です。比較の実行結果では、switchMap は結果3だけ、mergeMap は結果1〜3がすべて出ました。

Q. debounceTime と throttleTime の違いは? A. debounceTime は「入力が止まってから」最後の値を通し、throttleTime は「最初の値を通してから」一定時間は通しません。早見表の例では、同じ入力に対して debounceTime(150) は 3 だけ、throttleTime(150) は 1 と 3 を通しました。入力欄には前者、スクロールやボタンの間引きには後者が向いています。

Q. エラーのあと、検索が動かなくなりました。 A. catchError を外側に置いている可能性があります。外側に置くと、代わりの値を流したあと流れ全体が完了します(実行結果の B)。switchMap の中の Observable に catchError を付けると、次の入力も流れ続けます(実行結果の C)。

まとめ

  • オペレーターは pipe() に並べ、値は上から順に通る。よく使うのは早見表の35個。
  • Map 系の4つの違いは「処理中に次の値が来たとき」:mergeMap は並行、switchMap は打ち切って切り替え、concatMap は順番待ち、exhaustMap は新しいほうを無視。
  • 検索ボックスは debounceTime → distinctUntilChanged → switchMap。今回は入力 7 回がリクエスト 3 回になり、HttpClient のリクエストも実際にキャンセルされた。
  • 二重送信の防止は exhaustMap。連打 3 回+完了後 1 回で、送信は 2 回。
  • 並列取得は forkJoin、件数が多いときは mergeMap(fn, 同時実行数)。
  • catchError は、入力が続く流れでは内側に置く。外側に置くと、1回のエラーで流れが完了する。
  • RxJS 7.8 の型定義の案内に合わせ、combineLatestWith や firstValueFrom など新しい書き方を選ぶ。

参考・出典

  • RxJS 7.8.2 パッケージ同梱の型定義(rxjs/dist/types)の @deprecated コメント

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「RxJSオペレーター一覧|switchMapとmergeMapの違い」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?