Mert Özen Her Satırda Daha İleri
Frontend RxJS — #04

Higher-order Mapping: switchMap, mergeMap, concatMap ve exhaustMap Arasında Doğru Seçim

Mert Özen 14 Kas 2026 16 dk 12 görüntülenme
Higher-order Mapping: switchMap, mergeMap, concatMap ve exhaustMap Arasında Doğru Seçim

Arama gibi yalnız son değerin önemli olduğu okumalarda switchMap, sırası önemli yazmalarda concatMap, tekrarlanmaması gereken aksiyonlarda exhaustMap, bağımsız işlerde sınırlı mergeMap seçilir. Dört operator'ü aynı marble ile karşılaştırıyor, Bookstore'un arama kutusu, sepet adedi, çift tıklanan Siparişi Ver butonu ve 50 kapak indirmesiyle sanal zamanda çalıştırıyor, switchMap'in POST'ta neden tehlikeli olduğunu gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler RxJS 7.8.2, TypeScript 6.0 ve Node.js 24 ile denenmiştir; zamanlı örnekler RxJS'in TestScheduler'ı ile sanal zamanda çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) arama kutusuna kullanıcı 150 ms arayla "du", "dun" ve "dune" yazıyor. Her terim bir arama isteği başlatıyor. Kısa terim daha çok kitapla eşleştiği için API onu daha yavaş cevaplıyor: "du" 600 ms, "dun" 200 ms, "dune" 100 ms sürüyor. Kod şöyle:

terms$.pipe(mergeMap((term) => searchBooks(term))).subscribe(showResults);

İstekler 0, 150 ve 300 ms'de gidiyor. Ekranda en son hangi terimin sonuçları kalır? mergeMap yerine switchMap yazsaydık ne değişirdi?

Önceki yazıdaki operator'lerin hepsi bir değeri başka bir değere çeviriyordu. Bu yazıda bir değerin bir Observable başlattığı durumlara bakıyoruz: arama terimi bir HTTP isteğine, "Siparişi Ver" tıklaması bir POST'a, kapak listesi elli görsel indirmesine dönüşüyor. Bunun için RxJS'te dört temel operator var: switchMap, mergeMap, concatMap ve exhaustMap. Hangisini seçtiğin, kullanıcının ekranda ne gördüğünü ve sunucuda ne olduğunu doğrudan belirliyor.

Inner Observable ve Flatten Stratejisi

map((term) => searchBooks(term)) yazarsan elinde arama sonuçları değil, her terim için bir Observable olur: Observable<Observable<SearchResult>>. Dıştaki akışa outer, her değerin ürettiği akışa inner Observable denir. Higher-order mapping operator'leri inner Observable'a kendileri subscribe olup değerlerini tek bir akışa "düzleştirir" (flatten). Aralarındaki tek fark şu sorunun cevabı: önceki inner Observable henüz bitmemişken yeni bir outer değer gelirse ne olacak?

OperatorYeni değer gelinceTipik kullanım
switchMapÖncekini iptal eder (unsubscribe), yenisine geçerArama, filtre, route parametresine göre veri çekme
mergeMapÖncekiyle paralel çalıştırır; concurrent ile sınırlanabilirBirbirinden bağımsız işler: toplu indirme, loglama
concatMapKuyruğa koyar, önceki bitince başlatırSırası önemli yazma işlemleri
exhaustMapÖnceki bitene kadar yeni değeri yok sayarÇift tıklama, login, "Siparişi Ver"

Aynı girdiyle dördünü yan yana görelim. Önceki yazıdaki showMarbles yardımcı fonksiyonu burada da çıktıyı RxJS'in TestScheduler'ında üretip doğruluyor. Outer akış 1. frame'de a, 4. frame'de b yayıyor; her biri iki değer yayan ve 7 frame süren bir inner Observable başlatıyor (a'nınki A, b'ninki B yayıyor):

import { concatMap, exhaustMap, mergeMap, switchMap } from "rxjs";
import { showMarbles } from "../../tools/marble.ts";

// dört operator için aynı outer akış ve aynı inner Observable:
// a değeri iki kez "A" yayan, b değeri iki kez "B" yayan bir inner akış başlatır
const outer = "-a--b---------|";
const inner = "--X---X|";

showMarbles("mergeMap", (cold) => cold<string>(outer).pipe(mergeMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("concatMap", (cold) => cold<string>(outer).pipe(concatMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("switchMap", (cold) => cold<string>(outer).pipe(switchMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("exhaustMap", (cold) => cold<string>(outer).pipe(exhaustMap((v) => cold(inner, { X: v.toUpperCase() }))));
mergeMap
  in : -a--b---------|  
  in : --X---X|         X=A
  in : --X---X|         X=B
  out: ---A--BA--B---|  
concatMap
  in : -a--b---------|  
  in : --X---X|         X=A
  in : --X---X|         X=B
  out: ---A---A--B---B| 
switchMap
  in : -a--b---------|  
  in : --X---X|         X=A
  in : --X---X|         X=B
  out: ---A--B---B---|  
exhaustMap
  in : -a--b---------|  
  in : --X---X|         X=A
  out: ---A---A------|  

Dört "out" satırı dört stratejinin özeti. mergeMap'te A ve B'ler iç içe geçiyor: iki inner akış aynı anda çalışıyor. concatMap'te B'ler ancak a'nın inner akışı bittikten sonra (8. frame) başlıyor, bu yüzden çıktı en geç biten satır. switchMap'te b gelince a'nın inner akışı iptal edildi; ikinci A hiç gelmedi. exhaustMap'te ise tersi oldu: a'nın inner akışı sürerken gelen b yok sayıldı ve hiç B üretilmedi. Hiçbir operator değer kaybetmiyor diyemeyiz; switchMap ve exhaustMap bilerek kaybediyor, soru hangisinin senin senaryonda doğru kayıp olduğu.

Zamanlı Örnekleri Sanal Zamanda Çalıştırmak

Bu yazının senaryoları gerçek zamanda saniyeler sürerdi ve her koşuda milisaniyeler biraz oynardı. Bunun yerine snapshot'taki tools/virtual-time.ts her senaryoyu TestScheduler'ın run modunda çalıştırıyor. Bu modda timer, delay gibi zamana bağlı her şey sanal milisaniye kullanıyor: 5 saniyelik bir senaryo anında bitiyor ve her koşuda aynı zamanları yazıyor. log satır başına o anki sanal zamanı ekliyor, emitAt verilen anlarda değer yayan bir Observable kuruyor. Sahte HTTP çağrısı da şöyle:

// Sanal zamanlı örnekler için sahte bir HTTP çağrısı: isteği loglar, latencyMs sonra cevap verir,
// subscriber cevaptan önce unsubscribe olursa iptali loglar.
// onServer (opsiyonel): istek ulaşır ulaşmaz sunucunun yaptığı iş.
import { Observable, timer } from "rxjs";
import { log } from "../../tools/virtual-time.ts";

export function fakeRequest<T>(label: string, latencyMs: number, answer: () => T, onServer?: () => void): Observable<T> {
  return new Observable<T>((subscriber) => {
    log(`-> ${label}`);
    onServer?.();
    let answered = false;
    const pending = timer(latencyMs).subscribe(() => {
      answered = true;
      log(`<- ${label}`);
      subscriber.next(answer());
      subscriber.complete();
    });
    return () => {
      if (!answered) log(`x  ${label} cancelled`);
      pending.unsubscribe();
    };
  });
}

İlk yazıda yazdığımız getStock'un sanal zamanlı kardeşi: -> istek gitti, <- cevap geldi, x istek cevap gelmeden iptal edildi demek. Teardown'daki iptal logu, Angular'ın HttpClient'ında unsubscribe'ın isteği iptal etmesinin karşılığı.

Arama Kutusu: switchMap

Tahmindeki senaryoyu dört operator'le de çalıştıralım:

import { concatMap, exhaustMap, mergeMap, switchMap, type OperatorFunction } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";

type SearchResult = { term: string; titles: string[] };

// kısa terim daha çok kitapla eşleşir, API onu daha yavaş cevaplar
const latency: Record<string, number> = { du: 600, dun: 200, dune: 100 };

function searchBooks(term: string) {
  return fakeRequest(`GET /books?q=${term}`, latency[term], (): SearchResult => ({
    term,
    titles: books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
  }));
}

function runSearch(name: string, flatten: OperatorFunction<string, SearchResult>) {
  runInVirtualTime(name, () => {
    // kullanıcı 150 ms arayla "du", "dun", "dune" yazıyor
    const terms$ = emitAt([0, "du"], [150, "dun"], [300, "dune"]);
    terms$.pipe(flatten).subscribe((r) => log(`screen: "${r.term}" -> ${r.titles.join(", ")}`));
  });
}

runSearch("mergeMap", mergeMap((term) => searchBooks(term)));
runSearch("concatMap", concatMap((term) => searchBooks(term)));
runSearch("switchMap", switchMap((term) => searchBooks(term)));
runSearch("exhaustMap", exhaustMap((term) => searchBooks(term)));
--- mergeMap
    0 ms  -> GET /books?q=du
  150 ms  -> GET /books?q=dun
  300 ms  -> GET /books?q=dune
  350 ms  <- GET /books?q=dun
  350 ms  screen: "dun" -> Dune
  400 ms  <- GET /books?q=dune
  400 ms  screen: "dune" -> Dune
  600 ms  <- GET /books?q=du
  600 ms  screen: "du" -> Dune
--- concatMap
    0 ms  -> GET /books?q=du
  600 ms  <- GET /books?q=du
  600 ms  screen: "du" -> Dune
  600 ms  -> GET /books?q=dun
  800 ms  <- GET /books?q=dun
  800 ms  screen: "dun" -> Dune
  800 ms  -> GET /books?q=dune
  900 ms  <- GET /books?q=dune
  900 ms  screen: "dune" -> Dune
--- switchMap
    0 ms  -> GET /books?q=du
  150 ms  x  GET /books?q=du cancelled
  150 ms  -> GET /books?q=dun
  300 ms  x  GET /books?q=dun cancelled
  300 ms  -> GET /books?q=dune
  400 ms  <- GET /books?q=dune
  400 ms  screen: "dune" -> Dune
--- exhaustMap
    0 ms  -> GET /books?q=du
  600 ms  <- GET /books?q=du
  600 ms  screen: "du" -> Dune

Tahminin cevabı mergeMap bloğunda: ekranda en son "du" sonuçları kaldı. Kullanıcı "dune" yazmış, ama en yavaş istek en son döndüğü için 600. ms'de ekran eski terimin sonuçlarıyla güncellendi. Bu bir race condition ve test ortamında genelde görünmüyor, çünkü yerel API'de istekler hep aynı hızda dönüyor. Bookstore'un 40 bin kitaplık kataloğunda geniş bir arama ile dar bir arama arasındaki süre farkı ise production'da tam olarak bu sırayı üretiyor.

concatMap doğru sonucu gösterdi ama 900 ms'de; kullanıcı artık ilgilenmediği iki aramanın bitmesini bekledi. exhaustMap en kötüsü: "du" bitene kadar gelen terimleri yok saydı ve "dune" hiç aranmadı. switchMap ise her yeni terimde önceki isteği iptal etti (x satırları) ve yalnız son terimin sonucunu 400 ms'de gösterdi. Arama, filtre ve route parametresi gibi "yalnız son değer önemli" senaryolarının operator'ü switchMap. Gerçek arama kutusunda bunun önüne bir debounceTime eklenir; zaman operator'leri bu serinin zaman yazısında.

Sepette Adet Değiştirmek: concatMap

Kullanıcı sepet satırında Dune'un adedini "+" ile üç kez artırıyor: 2, 3, 4. Her tıklama bir PUT isteği atıyor. Ağdaki dalgalanma yüzünden bu koşuda sonraki istekler daha hızlı cevaplanıyor:

import { concatMap, mergeMap, type OperatorFunction } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";

// ağ dalgalanması: bu koşuda sonraki istekler tesadüfen daha hızlı cevaplanıyor
const latency: Record<number, number> = { 2: 300, 3: 200, 4: 100 };

function setQuantity(quantity: number) {
  return fakeRequest(`PUT /cart/items/9780441172719 quantity=${quantity}`, latency[quantity], () => quantity);
}

function runStepper(name: string, flatten: OperatorFunction<number, number>) {
  runInVirtualTime(name, () => {
    // kullanıcı Dune'da üç kez "+"ya basıyor: 1 -> 2 -> 3 -> 4
    const quantities$ = emitAt([0, 2], [50, 3], [100, 4]);
    quantities$.pipe(flatten).subscribe((q) => log(`cart line shows quantity ${q}`));
  });
}

runStepper("mergeMap", mergeMap((q) => setQuantity(q)));
runStepper("concatMap", concatMap((q) => setQuantity(q)));
--- mergeMap
    0 ms  -> PUT /cart/items/9780441172719 quantity=2
   50 ms  -> PUT /cart/items/9780441172719 quantity=3
  100 ms  -> PUT /cart/items/9780441172719 quantity=4
  200 ms  <- PUT /cart/items/9780441172719 quantity=4
  200 ms  cart line shows quantity 4
  250 ms  <- PUT /cart/items/9780441172719 quantity=3
  250 ms  cart line shows quantity 3
  300 ms  <- PUT /cart/items/9780441172719 quantity=2
  300 ms  cart line shows quantity 2
--- concatMap
    0 ms  -> PUT /cart/items/9780441172719 quantity=2
  300 ms  <- PUT /cart/items/9780441172719 quantity=2
  300 ms  cart line shows quantity 2
  300 ms  -> PUT /cart/items/9780441172719 quantity=3
  500 ms  <- PUT /cart/items/9780441172719 quantity=3
  500 ms  cart line shows quantity 3
  500 ms  -> PUT /cart/items/9780441172719 quantity=4
  600 ms  <- PUT /cart/items/9780441172719 quantity=4
  600 ms  cart line shows quantity 4

mergeMap ile sepet satırı 4 → 3 → 2 diye geriye saydı ve 2'de kaldı; kullanıcı 4 istemişti. Daha kötüsü, gerçek bir ağda paralel giden üç PUT'un sunucuya hangi sırayla ulaşacağı da garanti değil, yani sunucudaki değer de 2 ya da 3 olabilir. concatMap her isteği öncekinin cevabından sonra gönderdi: istekler sırayla gitti, ekran 2, 3, 4 diye ilerledi ve sunucu ile ekran aynı değerde kaldı. Bedeli süre: son değer 600 ms'de geldi. Sırası önemli yazma işlemlerinde (adet güncelleme, sıralı adımlar, aynı kaydı değiştiren ardışık PATCH'ler) bu bedel ödenir. concatMap, mergeMap(project, 1) ile aynı davranıyor; aradaki kuyruk bellekte tutulduğu için kullanıcı çok hızlı tıklarsa kuyruk da büyür.

"Siparişi Ver" Çift Tıklama: exhaustMap

Ada, 770 TL'lik siparişini (Dune ve Emma) veriyor ve butona alışkanlıkla çift tıklıyor, ardından bir kez daha basıyor. Sunucu siparişi istek ulaştığı anda oluşturuyor ve ödemeyi alıyor, cevabı 500 ms sonra dönüyor:

import { defer, exhaustMap, mergeMap, switchMap, type OperatorFunction } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";

let nextOrderId = 1042;

// sunucu siparişi istek ulaşır ulaşmaz oluşturur, 500 ms sonra cevap verir
function placeOrder() {
  return defer(() => {
    const orderId = nextOrderId++;
    return fakeRequest(`POST /orders (order ${orderId})`, 500, () => orderId, () =>
      log(`   server: order ${orderId} created for Ada, 770 TRY charged`),
    );
  });
}

function runCheckout(name: string, flatten: OperatorFunction<string, number>) {
  nextOrderId = 1042;
  runInVirtualTime(name, () => {
    // Ada "Place Order"a çift tıklıyor, sonra bir kez daha basıyor
    const clicks$ = emitAt([0, "click"], [80, "click"], [160, "click"]);
    clicks$.pipe(flatten).subscribe((id) => log(`screen: thank you, order ${id}`));
  });
}

runCheckout("mergeMap", mergeMap(() => placeOrder()));
runCheckout("switchMap", switchMap(() => placeOrder()));
runCheckout("exhaustMap", exhaustMap(() => placeOrder()));
--- mergeMap
    0 ms  -> POST /orders (order 1042)
    0 ms     server: order 1042 created for Ada, 770 TRY charged
   80 ms  -> POST /orders (order 1043)
   80 ms     server: order 1043 created for Ada, 770 TRY charged
  160 ms  -> POST /orders (order 1044)
  160 ms     server: order 1044 created for Ada, 770 TRY charged
  500 ms  <- POST /orders (order 1042)
  500 ms  screen: thank you, order 1042
  580 ms  <- POST /orders (order 1043)
  580 ms  screen: thank you, order 1043
  660 ms  <- POST /orders (order 1044)
  660 ms  screen: thank you, order 1044
--- switchMap
    0 ms  -> POST /orders (order 1042)
    0 ms     server: order 1042 created for Ada, 770 TRY charged
   80 ms  x  POST /orders (order 1042) cancelled
   80 ms  -> POST /orders (order 1043)
   80 ms     server: order 1043 created for Ada, 770 TRY charged
  160 ms  x  POST /orders (order 1043) cancelled
  160 ms  -> POST /orders (order 1044)
  160 ms     server: order 1044 created for Ada, 770 TRY charged
  660 ms  <- POST /orders (order 1044)
  660 ms  screen: thank you, order 1044
--- exhaustMap
    0 ms  -> POST /orders (order 1042)
    0 ms     server: order 1042 created for Ada, 770 TRY charged
  500 ms  <- POST /orders (order 1042)
  500 ms  screen: thank you, order 1042

mergeMap ile Ada'nın kartından üç kez 770 TL çekildi ve ekranda üç teşekkür mesajı göründü. switchMap daha sinsi: ekranda tek bir sipariş (1044) görünüyor, her şey doğru çalışmış gibi. Ama sunucu logunda üç sipariş var. İstemci ilk iki isteği iptal etti, yani cevaplarını dinlemeyi bıraktı; sunucu ise istekleri çoktan almış ve işlemişti. Unsubscribe, gönderilmiş bir HTTP isteğini geri almaz, en fazla cevabın beklenmesini durdurur. Bu yüzden POST, PUT, DELETE gibi yan etkisi olan isteklerde switchMap tehlikeli: kullanıcı hiçbir şey görmeden sunucuda yarım kalmış ya da tekrarlanmış iş bırakır.

exhaustMap ilk tıklamayı işleme aldı, istek sürerken gelen iki tıklamayı yok saydı: tek sipariş, tek ödeme. "Siparişi Ver", "Giriş Yap", "Ödemeyi Onayla" gibi işlem sürerken tekrarlanmaması gereken her aksiyonun operator'ü bu. Yine de tek savunma hattı olarak bırakma: başka sekmeden, retry'dan ya da ağ kesintisinden gelen tekrarları sunucu tarafında bir idempotency key ile engellemek ayrıca gerekiyor; bu mekanizma Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısında anlatıldı.

50 Kapak, En Fazla 4 Paralel: mergeMap(…, 4)

Yönetim ekranında 50 kitabın kapak görseli yükleniyor. İşler birbirinden bağımsız, sıra önemli değil; bu mergeMap'in alanı. Ama sınırsız paralellik de sorun:

import { concatMap, defer, finalize, map, mergeMap, range, timer, type OperatorFunction } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";

let active = 0;
let maxActive = 0;

// bir kapak görselinin yüklenmesi 100 ms sürüyor; active/maxActive paralel indirmeleri sayar
function loadCover(index: number) {
  return defer(() => {
    active++;
    maxActive = Math.max(maxActive, active);
    return timer(100).pipe(
      map(() => `cover-${index}.jpg`),
      finalize(() => active--),
    );
  });
}

function runCovers(name: string, flatten: OperatorFunction<number, string>) {
  active = 0;
  maxActive = 0;
  runInVirtualTime(name, () => {
    let loaded = 0;
    range(1, 50)
      .pipe(flatten)
      .subscribe({
        next: () => loaded++,
        complete: () => log(`${loaded} covers loaded, at most ${maxActive} at the same time`),
      });
  });
}

runCovers("mergeMap (no limit)", mergeMap((i) => loadCover(i)));
runCovers("mergeMap(..., 4)", mergeMap((i) => loadCover(i), 4));
runCovers("concatMap", concatMap((i) => loadCover(i)));
--- mergeMap (no limit)
  100 ms  50 covers loaded, at most 50 at the same time
--- mergeMap(..., 4)
 1300 ms  50 covers loaded, at most 4 at the same time
--- concatMap
 5000 ms  50 covers loaded, at most 1 at the same time

Sınırsız mergeMap 50 isteği aynı anda başlattı ve sanal zamanda 100 ms'de bitti. Gerçek dünyada bu rakam yanıltıcı: tarayıcı HTTP/1.1'de aynı host'a sınırlı sayıda bağlantı açtığı için istekler zaten tarayıcının kuyruğunda bekler, sunucu ve CDN (içerik dağıtım ağı) de aynı anda gelen 50 isteği rate limit'e takabilir. Bunun da ötesinde, kullanıcının gördüğü ilk kapaklar bu kalabalığın içinde yavaşlar. concatMap diğer uç: tek tek, 5 saniye. mergeMap'in ikinci parametresi concurrent ikisinin arasını veriyor: en fazla 4 paralel istek, 1,3 saniye. Doğru sayı ölçerek bulunur; sunucunun rate limit'i ve tarayıcının bağlantı davranışı (HTTP/2'de tek bağlantı üzerinden çoklu istek) bu sayıyı belirler.

Sayfalı API'yi Sonuna Kadar Çekmek: expand

Bazen bir sonraki isteğin parametresi bir önceki cevabın içinde gelir: sayfalı bir API "sonraki sayfa şu offset'ten başlar" der. expand, her değeri yayıp aynı zamanda o değerden yeni bir inner Observable üreten, yani kendini besleyen bir mergeMap:

import { EMPTY, expand, reduce } from "rxjs";
import { books } from "../../data/books.ts";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";

type CatalogPage = { items: string[]; nextOffset: number | null };

// katalog API'si sayfa başına 2 kitap döner ve sonraki sayfanın nereden başladığını söyler
function getCatalogPage(offset: number) {
  return fakeRequest(`GET /books?offset=${offset}&limit=2`, 100, (): CatalogPage => ({
    items: books.slice(offset, offset + 2).map((b) => b.title),
    nextOffset: offset + 2 < books.length ? offset + 2 : null,
  }));
}

runInVirtualTime("expand", () => {
  getCatalogPage(0)
    .pipe(
      expand((page) => (page.nextOffset === null ? EMPTY : getCatalogPage(page.nextOffset))),
      reduce((all, page) => [...all, ...page.items], [] as string[]),
    )
    .subscribe((titles) => log(`${titles.length} books: ${titles.join(", ")}`));
});
--- expand
    0 ms  -> GET /books?offset=0&limit=2
  100 ms  <- GET /books?offset=0&limit=2
  100 ms  -> GET /books?offset=2&limit=2
  200 ms  <- GET /books?offset=2&limit=2
  200 ms  -> GET /books?offset=4&limit=2
  300 ms  <- GET /books?offset=4&limit=2
  300 ms  6 books: Dune, Emma, The Hobbit, Middlemarch, Frankenstein, Pride and Prejudice

Durma koşulu EMPTY: son sayfada nextOffset null geldiğinde yeni istek üretilmiyor, inner akışlar bitince expand de tamamlanıyor ve önceki yazıdaki reduce bütün sayfaları tek bir listede topluyor. Bu, reduce'un doğru yeri: akış gerçekten bitiyor. Durma koşulunu unutursan (ya da API son sayfada yanlışlıkla bir offset dönerse) expand sonsuza kadar istek atar; production'da bir üst sınır (take(100) gibi) koymak ucuz bir sigorta.

Üretimde Dikkat

Yanlış flatten stratejisinin belirtileri genelde "ara sıra" olur, bu yüzden ölçmek gerekiyor. Arama kutusunda DevTools'un Network sekmesinde aynı anda kaç arama isteğinin beklediğine bak; switchMap ile iptal edilen istekler "(canceled)" durumuyla görünür, birikmiş pending istekler ise yanlış operator'ün işaretidir. Ağı yavaşlatmak (Network throttling ile "Slow 4G") race condition'ı geliştirici makinesinde de görünür yapar.

Yazma işlemlerinde asıl ölçü sunucu tarafında: aynı müşteri için birkaç yüz milisaniye arayla oluşan iki sipariş, ya da sırası karışmış güncelleme logları. Bookstore'un sipariş tablosunda "aynı müşteri, aynı tutar, 1 saniyeden kısa arayla" oluşan siparişleri sayan basit bir rapor, istemcideki yanlış bir mergeMap'i ya da eksik bir exhaustMap'i hemen gösterir.

Seçim için kısa bir kural: önce işin okuma mı yazma mı olduğuna bak. Okuma işlemlerinde (GET) iptal güvenlidir, bu yüzden "yalnız son değer önemli" ise switchMap, hepsi önemliyse sınırlı mergeMap. Yazma işlemlerinde iptal güvenli değildir: sıra önemliyse concatMap, tekrar istenmiyorsa exhaustMap. switchMap'i yazma işlemine koymadan önce sunucunun yarım kalan bir isteği nasıl ele aldığını bildiğinden emin ol.

Sık Düşülen Tuzaklar

  • Arama ya da filtrede mergeMap kullanmak. Cevaplar sırasız döner, en yavaş eski istek ekranı son günceller.
  • POST, PUT, DELETE isteklerinde switchMap kullanmak. İptal yalnız istemci tarafındadır; sunucu isteği çoktan işlemiş olabilir.
  • Sınırsız mergeMap ile yüzlerce istek başlatmak. concurrent parametresiyle bir üst sınır koy.
  • Arama kutusunda exhaustMap ya da concatMap kullanmak. Kullanıcının son yazdığı terim ya hiç aranmaz ya da artık ilgilenmediği aramaların bitmesini bekler.
  • expand'de durma koşulunu (EMPTY) unutmak ya da yalnız API'nin doğru cevap vereceğine güvenmek.
  • Higher-order operator yerine subscribe içinde subscribe yazmak. İç subscription'ı kimse iptal etmez ve hangi stratejinin uygulandığı koddan okunmaz; bu, mergeMap'in sınırsız ve kontrolsüz hâlidir.

Küçük Bir Deneme

★☆☆ 01-search.ts'te gecikmeleri { du: 100, dun: 200, dune: 600 } yap. Dört operator'ün çıktısını önce tahmin et, sonra çalıştır. Hangi operator'ün sonucu değişti ve neden switchMap'inki değişmedi?

★★☆ 04-place-order.ts'e dördüncü bir senaryo ekle: concatMap. Ada'nın üç tıklamasında sunucuda kaç sipariş oluşur? Sonra exhaustMap'e, istek sürerken butonu devre dışı bırakmak için bir log("button disabled") ve log("button enabled") ekle; bunları akışın neresine koyduğunu ve neden subscribe içine koymadığını açıkla.

★★★ 05-covers.ts'teki loadCover'ı, her 10. kapakta 300 ms süren "büyük görsel" olacak şekilde değiştir. mergeMap(…, 4) ile toplam süre ve kapakların yüklenme sırası nasıl değişiyor? Kapakları ekranda yine 1'den 50'ye sırayla göstermek istesen hangi operator'ü seçerdin ve paralellikten ne kaybederdin?

Bu yazıda tek bir akışın her değerinden yeni bir akış ürettik. Bir sonraki yazıda farklı akışları birleştireceğiz: katalog sayfasındaki kategori, fiyat aralığı ve sıralama filtrelerini combineLatest ile tek bir sorguya çevirecek, sipariş detayında üç API çağrısını forkJoin ile paralel yapacak ve withLatestFrom, zip, merge, concat ve race'in hangi soruya cevap verdiğine bakacağız.