- Angular
RxJSオペレーター一覧|switchMapとmergeMapの違い
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 に123を流すb$:50ms・150ms にxyを流す
作る(作成関数)
| 名前 | すること | 例 → 出力 |
|---|---|---|
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期連続の黒字決算。