BACK TO STORIESSTORIES · 読み物

RxJS: zip vs combineLatest vs withLatestFrom vs forkJoin

rxjs zip vs combinelatest vs withlatestfrom vs forkjoin

RxJS: zip vs combineLatest vs withLatestFrom vs forkJoin


Se lavori con Angular, da RxJS non scappi. E prima o poi arriva il momento in cui devi combinare due o più observable, apri la documentazione e trovi quattro operatori che sembrano fare tutti la stessa cosa: zip, combineLatest, withLatestFrom e forkJoin. Non la fanno. La differenza sta tutta nel quando emettono, e sceglierne uno sbagliato è il modo più rapido per ritrovarsi con una richiesta che parte due volte o uno spinner che gira per sempre.


Questa oneshot è il remake di un pezzo che avevo scritto nel 2023: stessa storia, ridisegnata da cima a fondo. E stavolta con tutti e quattro i protagonisti in scena.


🎨 Lo scenario


Dobbiamo disegnare delle icone colorate, e le informazioni ci arrivano da due persone diverse: la prima ci dice il colore, la seconda il tipo di icona. Le due persone non sono sincronizzate tra loro: parlano quando vogliono, ognuna per conto suo. Nel nostro codice sono due observable, color$ e icon$.

TYPESCRIPT · コード
1import { Subject } from 'rxjs';
2
3type Color = 'red' | 'blue' | 'green' | 'purple';
4type Icon = 'github' | 'bitbucket' | 'gitlab';
5
6// le due persone che ci comunicano le informazioni
7const color$ = new Subject<Color>();
8const icon$ = new Subject<Icon>();
9
10// e questo è l'ordine in cui parleranno
11color$.next('red');
12icon$.next('github');
13
14color$.next('blue');
15icon$.next('bitbucket');
16
17color$.next('green');
18icon$.next('gitlab');
19
20color$.next('purple');

Nota il finale: quattro colori, ma solo tre icone. Questo piccolo squilibrio è quello che renderà visibili le differenze tra gli operatori.


🤐 zip: uno per ciascuno, in coppia


zip accoppia le emissioni per posizione: il primo colore con la prima icona, il secondo con la seconda, e così via. Come una cerniera, appunto.

TYPESCRIPT · コード
1import { zip } from 'rxjs';
2
3zip(color$, icon$).subscribe(([color, icon]) =>
4  console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// → github red
8// → bitbucket blue
9// → gitlab green
10// ("purple" resta in attesa di un'icona che non arriverà)
Diagramma dell'operatore zip

Per emettere una nuova coppia devono arrivare entrambi i valori: il quarto colore rimane in canna, perché la quarta icona non esiste.


Quando usarlo: quando i due stream avanzano davvero in coppia e ti serve l'accoppiata esatta per indice. È il caso più raro dei quattro.

Il lato oscuro di zip: se una sorgente è più veloce dell'altra, i suoi valori si accumulano in un buffer in attesa del compagno. Su stream infiniti e sbilanciati è memoria che cresce, silenziosamente.

🔄 combineLatest: reagisci a ogni cambiamento


Il grande assente della prima versione di questo articolo, e ironicamente il più usato dei quattro. combineLatest emette l'ultima combinazione disponibile ogni volta che una qualsiasi delle sorgenti parla.

TYPESCRIPT · コード
1import { combineLatest } from 'rxjs';
2
3combineLatest([color$, icon$]).subscribe(([color, icon]) =>
4  console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// → github red        (prima combinazione completa)
8// → github blue       (cambia il colore, l'icona resta)
9// → bitbucket blue    (cambia l'icona, il colore resta)
10// → bitbucket green
11// → gitlab green
12// → gitlab purple
Diagramma dell'operatore combineLatest

Ogni novità, da qualunque parte arrivi, produce una nuova coppia con "l'ultimo valore noto" dell'altra sorgente.


Quando usarlo: è l'operatore dei form con più filtri. Query di ricerca, pagina, ordinamento: al cambio di uno qualsiasi, ricalcoli il risultato con gli ultimi valori di tutti.

TYPESCRIPT · コード
combineLatest([query$, page$, sort$])
  .pipe(switchMap(fetchResults))
  .subscribe(render);

Il gotcha classico: combineLatest non emette finché tutte le sorgenti hanno parlato almeno una volta. Se un filtro parte "muto", niente risultati e nessun errore. La cura è dargli un valore iniziale, con startWith o con un BehaviorSubject.

🎯 withLatestFrom: un protagonista, un contesto


Qui il rapporto non è più alla pari: c'è una sorgente primaria che comanda, e una secondaria che viene solo consultata. Nel nostro caso il colore decide quando disegnare, e l'icona fa da contesto.

TYPESCRIPT · コード
1import { withLatestFrom } from 'rxjs/operators';
2
3color$
4  .pipe(withLatestFrom(icon$))
5  .subscribe(([color, icon]) =>
6    console.log(`Disegnata icona ${icon} di colore ${color}`)
7  );
8
9// → github blue      (al secondo colore: il primo è passato
10//                     quando icon$ non aveva ancora un valore)
11// → bitbucket green
12// → gitlab purple
Diagramma dell'operatore withLatestFrom

Solo le emissioni di color$ scatenano l'output; icon$ può parlare quanto vuole, da solo non produce nulla.


Quando usarlo: quando un evento deve leggere uno stato al volo. Il click che parte con l'ultimo valore del form, il submit che allega la configurazione corrente: l'evento comanda, lo stato accompagna.

Anche qui, occhio all'avvio: finché la secondaria non ha emesso almeno un valore, le emissioni della primaria vengono scartate. È il motivo per cui nel nostro esempio il colore "red" sparisce senza lasciare traccia.

🏁 forkJoin: aspetta che tutti abbiano finito


forkJoin è il più drastico: ignora tutto quello che succede durante il viaggio ed emette una volta sola, quando tutte le sorgenti hanno completato, con l'ultimo valore di ciascuna.

TYPESCRIPT · コード
1import { forkJoin } from 'rxjs';
2
3forkJoin([color$, icon$]).subscribe(([color, icon]) =>
4  console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// ...a un certo punto le due persone salutano e se ne vanno
8color$.complete();
9icon$.complete();
10
11// → gitlab purple    (una sola emissione: gli ultimi valori)
Diagramma dell'operatore forkJoin

Quando usarlo: è il Promise.all di RxJS. Un gruppo di chiamate HTTP che ti servono tutte insieme prima di renderizzare la pagina: forkJoin aspetta, raccoglie e consegna in un colpo solo. (Con le chiamate di HttpClient funziona alla perfezione, perché completano da sole dopo la risposta.)

Ed ecco lo spinner infinito promesso in apertura: se anche una sola sorgente non completa mai (un Subject dimenticato aperto, uno stream di eventi), forkJoin non emetterà mai niente. Nessun errore, nessun avviso: solo un caricamento eterno. Se ti è successo, ora sai chi era.

🧭 Quale scelgo? Il bigino


  • zip: mi servono le coppie per posizione, primo con primo, secondo con secondo
  • combineLatest: voglio reagire a ogni cambiamento con gli ultimi valori di tutti (form, filtri)
  • withLatestFrom: ho un evento che comanda e uno stato da leggere al passaggio
  • forkJoin: aspetto che tutte le sorgenti finiscano e mi serve solo il risultato finale (gruppi di HTTP)

✅ Conclusione


Quattro operatori che in superficie "combinano observable", e quattro contratti completamente diversi su quando emettere. È il motivo per cui sembrano intercambiabili nei tutorial e poi esplodono in produzione: la differenza non si vede nel caso felice, si vede quando gli stream sono sbilanciati, muti o infiniti. Cioè sempre, nel mondo vero.


La prossima volta che devi combinarne due, non chiederti "quale operatore conosco": chiediti chi deve comandare l'emissione. La risposta a quella domanda è l'operatore giusto.