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$.
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.
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à)
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.
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
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.
combineLatest([query$, page$, sort$])
.pipe(switchMap(fetchResults))
.subscribe(render);Il gotcha classico:
combineLatestnon 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, constartWitho con unBehaviorSubject.
🎯 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.
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
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.
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)
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),
forkJoinnon 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.