Lección 2 de 415 min
RxJS: programación reactiva
Observables, operadores, Subjects y la diferencia entre switchMap, mergeMap, concatMap y exhaustMap.
Piensa en un buscador: el usuario escribe «ang», luego «angu» y luego «angular». No quieres lanzar una petición por cada tecla, y si las lanzas, no quieres que la respuesta lenta de «ang» llegue la última y pise los resultados buenos. Resolver eso con promesas y temporizadores sueltos es un lío.
RxJS trata los eventos, las peticiones y los temporizadores como flujos de valores en el tiempo, los observables, y te da operadores para transformarlos, filtrarlos, combinarlos y cancelarlos. Angular lo usa en HttpClient, en el router y en los formularios reactivos: aunque los signals cubran el estado, RxJS sigue siendo la herramienta para todo lo que tiene que ver con el tiempo.
Observables y operadores
Observable y suscripción
Un productor perezoso de valores que solo se pone en marcha cuando alguien se suscribe.
- Termina con:
- complete o error
- Convención:
- nombre terminado en $
Un observable describe una fuente de valores que llegan con el tiempo: clics, respuestas HTTP, ticks de un temporizador. Emite con next cero, uno o muchos valores y termina de una de dos formas: con complete, si acaba bien, o con error, si falla. Después de cualquiera de las dos, no emite nada más.
Es perezoso: definirlo no hace nada. El trabajo empieza al llamar a subscribe(), y cada suscripción vuelve a ejecutar el productor desde cero. Por eso, si te suscribes dos veces a una petición de HttpClient, salen dos peticiones.
subscribe() devuelve una suscripción: al llamar a unsubscribe() dejas de recibir valores y el productor libera lo que estuviera usando.
Úsalo cuando
Para cualquier cosa que ocurra en el tiempo o que deba poder cancelarse: eventos del usuario, peticiones, WebSockets o temporizadores.
Ten en cuenta
Si te suscribes a algo que no termina nunca, como un interval o los cambios de un formulario, y no cancelas, la suscripción sigue viva aunque el componente desaparezca. Es una fuga de memoria; la verás al final de la lección.
import { Observable } from "rxjs";
const cuentaAtras$ = new Observable<number>((suscriptor) => {
let n = 3;
const id = setInterval(() => {
suscriptor.next(n--); // emite un valor
if (n < 0) suscriptor.complete(); // termina bien
}, 1000);
return () => clearInterval(id); // limpieza al cancelar o terminar
});
// Hasta aquí no ha pasado nada: es perezoso
cuentaAtras$.subscribe({
next: (n) => console.log(n), // 3, 2, 1, 0
complete: () => console.log("¡Despegue!"),
});Operadores básicos: map, filter, tap, debounceTime y distinctUntilChanged
Funciones que reciben un observable y devuelven otro, encadenadas con pipe.
Un operador convierte un flujo en otro. Se encadenan con pipe(), como en una cadena de montaje: map transforma cada valor, filter deja pasar solo los que cumplen una condición y tap ejecuta algo por cada valor sin cambiarlo, muy útil para depurar.
Dos operadores de tiempo aparecen en casi todos los buscadores: debounceTime(300) espera a que pasen 300 ms sin valores nuevos y emite solo el último, y distinctUntilChanged() descarta un valor si es igual al anterior.
Úsalo cuando
Para preparar los datos antes de usarlos: normalizar el texto de un buscador, ignorar valores vacíos o espaciar eventos que llegan demasiado rápido.
Ten en cuenta
tap es para efectos secundarios, como un registro, no para transformar: lo que devuelvas dentro se ignora. Y el orden de los operadores importa, igual que en una cadena de montaje.
import { debounceTime, distinctUntilChanged, filter, map, tap } from "rxjs";
// campoBusqueda = new FormControl("", { nonNullable: true })
const busqueda$ = this.campoBusqueda.valueChanges.pipe(
map((texto) => texto.trim().toLowerCase()),
filter((texto) => texto.length >= 2),
debounceTime(300), // espera a que el usuario deje de escribir
distinctUntilChanged(), // «ang» y «Ang » ya son iguales: no repite
tap((texto) => console.log("Buscar:", texto)),
);Operadores de aplanamiento
A menudo cada valor de un flujo dispara otro observable: cada texto del buscador lanza una petición; cada clic en «Guardar», un envío. Eso es un observable de observables, y los operadores de aplanamiento deciden qué hacer cuando llega un valor nuevo y el observable interior anterior aún no ha terminado. Los cuatro reciben la misma función; solo cambia esa decisión.
switchMap
Cancela lo que estaba en curso y se queda con lo último.
- Con otro en marcha:
- lo cancela
Cuando llega un valor nuevo, switchMap cancela el observable interior anterior (se desuscribe de él) y se cambia al nuevo. Solo te llegan los resultados de la petición más reciente.
Con HttpClient, desuscribirse cancela de verdad la petición en el navegador: no gastas red en respuestas que ya no te interesan y evitas que una respuesta antigua y lenta pise a una nueva.
Úsalo cuando
Búsquedas mientras se escribe, filtros, autocompletados y cambios de ruta: siempre que solo importe la última petición.
Ten en cuenta
No lo uses para escrituras: si el usuario pulsa «Guardar» dos veces, switchMap puede cancelar el primer guardado a medias y no sabrás si llegó al servidor.
resultados$ = this.busqueda$.pipe(
switchMap((texto) =>
this.http.get<Producto[]>("/api/productos", { params: { q: texto } }),
),
);mergeMap
Lo lanza todo en paralelo y mezcla los resultados según llegan.
- Con otro en marcha:
- lanza otro en paralelo
mergeMap se suscribe a cada observable interior en cuanto llega su valor y deja que corran a la vez. Los resultados salen en el orden en que terminan, no en el orden en que empezaron.
Admite un segundo argumento para limitar la concurrencia: mergeMap(fn, 3) mantiene como mucho tres en marcha y deja el resto en espera.
Úsalo cuando
Para tareas independientes cuyo orden no importa: subir varias fotos, marcar varios mensajes como leídos o enviar eventos de analítica.
Ten en cuenta
Sin límite de concurrencia, mil valores lanzan mil peticiones a la vez. Y si el orden de los resultados importa, no es tu operador.
// Sube cada foto en cuanto se elige, como mucho tres a la vez
subidas$ = this.fotosElegidas$.pipe(
mergeMap((foto) => this.api.subir(foto), 3),
);concatMap
Hace cola: cada observable interior espera a que termine el anterior.
- Con otro en marcha:
- lo pone en cola
concatMap procesa los observables interiores de uno en uno, en el orden en que llegaron sus valores. Mientras uno está en marcha, los siguientes esperan en una cola; no se cancela ni se pierde ninguno.
Es la opción segura para operaciones que deben aplicarse en orden, como varias escrituras sobre el mismo recurso.
Úsalo cuando
Para guardar en orden: el autoguardado de un documento, una cola de cambios sobre el mismo registro o pasos que dependen del anterior.
Ten en cuenta
Si un observable interior no termina nunca, la cola se bloquea para siempre. Y si los valores llegan más rápido de lo que se procesan, la cola crece sin parar.
// Cada borrador se guarda después del anterior, nunca a la vez
autoguardado$ = this.cambios$.pipe(
debounceTime(500),
concatMap((borrador) => this.api.guardar(borrador)),
);exhaustMap
Ignora los valores nuevos mientras hay uno en marcha.
- Con otro en marcha:
- ignora el nuevo
exhaustMap hace lo contrario que switchMap: mientras el observable interior actual no termina, ignora los valores que lleguen. Cuando termina, vuelve a escuchar.
Es el antídoto contra el doble clic: aunque el usuario pulse el botón tres veces seguidas, solo sale una petición.
Úsalo cuando
Para envíos que no deben repetirse: iniciar sesión, pagar, enviar un formulario o renovar un token.
Ten en cuenta
Los valores ignorados se pierden para siempre, no se aplazan. Si cada clic cuenta, necesitas concatMap.
// Pulsar «Pagar» varias veces seguidas solo lanza un pago
pago$ = this.clicsPagar$.pipe(
exhaustMap(() => this.api.pagar(this.carrito())),
);Pruébalo
En el diagrama, elige el ejemplo «Ráfaga» y cambia de operador: con los mismos cuatro clics, switchMap deja pasar seis valores, exhaustMap seis distintos, y mergeMap y concatMap, los doce.
Interactivo
Diagrama de canicas: operadores de aplanamiento
Cada clic lanza una petición que emite tres valores. Elige un operador y mira qué valores llegan a la salida.
const salida$ = clics$.pipe( switchMap((clic) => peticion$(clic)), );
switchMap: Cuando llega un clic nuevo, cancela la petición en curso y se queda solo con la última.
Llegan a la salida 8 de 12 valores. 2 peticiones se cancelaron porque llegó un clic antes de que terminaran.
Caso típico: Búsqueda mientras escribes: solo te importa la respuesta a lo último que tecleaste, y las peticiones viejas se cancelan.
Subjects, fugas, combinaciones y errores
Subject, BehaviorSubject y ReplaySubject
Observables a los que tú les empujas valores con next() y que comparten la emisión.
Un Subject es a la vez observable y emisor: llamas a next(valor) desde fuera y todos sus suscriptores lo reciben. Un Subject normal no recuerda nada, así que quien se suscribe tarde se pierde lo anterior. Sirve para eventos, como «se ha cerrado la sesión».
BehaviorSubject necesita un valor inicial, recuerda el último y se lo entrega al instante a cada suscriptor nuevo; además, puedes leerlo con .value. ReplaySubject(n) recuerda los últimos n valores y se los repite a quien llega.
Antes de los signals, un servicio con un BehaviorSubject privado y un asObservable() público era la forma típica de compartir estado en Angular. Hoy, para estado, un signal suele ser más sencillo.
Úsalo cuando
Subject para eventos sin estado; BehaviorSubject para un valor actual que se comparte como flujo; ReplaySubject para repetir un historial corto a quien llega tarde.
Ten en cuenta
No expongas el Subject tal cual: cualquiera podría llamar a next(). Expón asObservable() y deja el emisor privado dentro del servicio.
import { BehaviorSubject, ReplaySubject, Subject } from "rxjs";
const cierreSesion = new Subject<void>();
const usuario = new BehaviorSubject<string | null>(null);
const avisos = new ReplaySubject<string>(3);
usuario.next("ana");
usuario.subscribe((u) => console.log(u)); // "ana": recibe el último al instante
console.log(usuario.value); // "ana"
avisos.next("A");
avisos.next("B");
avisos.subscribe((a) => console.log(a)); // "A" y "B": repite el historial
cierreSesion.next(); // nadie está suscrito: el evento se pierdeEvitar fugas: async y takeUntilDestroyed
Cada suscripción que abres hay que cerrarla; Angular puede hacerlo por ti.
Una suscripción a un flujo que no termina sigue viva aunque el componente se destruya: el código se sigue ejecutando, retiene memoria y puede intentar actualizar una pantalla que ya no existe. Hay que cancelarla, y lo mejor es no hacerlo a mano.
El pipe async se suscribe desde la plantilla y se desuscribe solo cuando se destruye el componente. Si te suscribes en la clase, takeUntilDestroyed(), de @angular/core/rxjs-interop, completa el flujo al destruirse el componente (fuera del constructor, pásale un DestroyRef). Y toSignal también gestiona la suscripción por ti.
Úsalo cuando
Siempre que te suscribas a algo que no termina por sí solo: interval, los valueChanges de un formulario, los eventos del router, un WebSocket o un store.
Ten en cuenta
Una petición de HttpClient termina sola tras la respuesta, así que no deja la suscripción abierta para siempre; aun así, cancelarla al salir evita trabajo inútil. Coloca takeUntilDestroyed al final del pipe, después de operadores como switchMap.
import { AsyncPipe } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { interval } from "rxjs";
@Component({
selector: "app-reloj",
imports: [AsyncPipe],
template: `<p>{{ segundos$ | async }}</p>`, // se suscribe y se desuscribe solo
})
export class Reloj {
readonly segundos$ = interval(1000);
constructor() {
interval(5000)
.pipe(takeUntilDestroyed()) // se completa al destruirse el componente
.subscribe(() => console.log("Sigo vivo"));
}
}Combinar flujos: combineLatest y forkJoin
combineLatest emite con cada cambio; forkJoin espera a que todo termine.
combineLatest([a$, b$]) emite un array con el último valor de cada fuente cada vez que cualquiera de ellas emite, pero solo cuando todas han emitido al menos una vez. Es ideal para combinar filtros que cambian, como la categoría y el orden.
forkJoin([a$, b$]) espera a que todas terminen y emite una sola vez con el último valor de cada una, como Promise.all. Es perfecto para cargar en paralelo varias peticiones independientes antes de pintar una pantalla.
Úsalo cuando
combineLatest para estado que cambia una y otra vez; forkJoin para un lote de peticiones que terminan.
Ten en cuenta
forkJoin con una fuente que nunca termina no emite jamás, y si una fuente falla, falla todo. combineLatest no emite hasta que todas las fuentes han emitido algo; startWith puede darles un valor inicial.
import { combineLatest, forkJoin, switchMap } from "rxjs";
// Cada vez que cambia un filtro, vuelve a pedir la lista
const lista$ = combineLatest([this.categoria$, this.orden$]).pipe(
switchMap(([categoria, orden]) => this.api.productos(categoria, orden)),
);
// Tres peticiones en paralelo; emite una vez, cuando terminan todas
forkJoin({
usuario: this.api.usuario(),
pedidos: this.api.pedidos(),
avisos: this.api.avisos(),
}).subscribe((datos) => this.pintar(datos));Errores: catchError y retry
Recuperarse de un fallo con un valor alternativo o reintentar antes de rendirse.
Cuando un observable emite un error, termina. catchError intercepta ese error y lo sustituye por otro observable: un valor por defecto con of([]), o un error más claro con throwError.
retry vuelve a suscribirse si hay un fallo, es decir, repite la petición. Con un objeto de configuración decides cuántas veces y cuánto esperar entre intentos: retry({ count: 3, delay: 1000 }).
Úsalo cuando
retry para fallos pasajeros, como un corte de red o un servidor saturado; catchError para que un error no rompa la pantalla.
Ten en cuenta
Dentro de un switchMap, pon catchError en el observable interior: en el exterior, el primer error termina todo el flujo y el buscador deja de responder. Y no reintentes errores que no van a cambiar, como un 404 o un 403.
import { catchError, of, retry, switchMap } from "rxjs";
resultados$ = this.busqueda$.pipe(
switchMap((texto) =>
this.api.buscar(texto).pipe(
retry({ count: 2, delay: 1000 }), // reintenta los fallos pasajeros
catchError(() => {
this.aviso.set("No se pudo buscar. Inténtalo de nuevo.");
return of([]); // dentro: el buscador sigue vivo
}),
),
),
);Interactivo
Los cuatro operadores de aplanamiento
Todos reciben la misma función. La diferencia está en qué hacen si llega un valor nuevo con otro observable interior todavía en marcha.
| Característica | switchMap | mergeMap | concatMap |
|---|---|---|---|
| Si llega un valor con otro en marcha | Cancela el anterior | Lanza otro en paralelo | Lo pone en cola |
| Interiores a la vez | Uno | Todos, o el límite que pongas | Uno |
| Orden de los resultados | En orden, cortando lo cancelado | Según van terminando | El de llegada |
| ¿Se pierde algo? | Sí, lo cancelado | No | No |
| Caso típico | Búsqueda mientras escribes | Subidas independientes | Guardar en orden |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Un campo de ciudad sugiere resultados mientras el usuario escribe. Solo importan las sugerencias para el texto actual.
Para recordar
- Un observable es perezoso: no hace nada hasta que alguien se suscribe, y termina con
completeo conerror. - Los operadores se encadenan con
pipe;debounceTimeydistinctUntilChangedson la base de cualquier buscador. - Si llega un valor con otro en marcha:
switchMapcancela,mergeMapparaleliza,concatMaphace cola yexhaustMapignora. - Cierra lo que abres: con el pipe
async, contakeUntilDestroyedo contoSignal. - Pon
catchErrordentro del observable interior para que un error no termine el flujo entero.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.Escribes
const datos$ = this.http.get("/api/datos")y no te suscribes. ¿Qué pasa?2.Con
switchMap, el usuario escribe «ang» y, antes de que llegue la respuesta, escribe «angular». ¿Qué ocurre con la primera petición?3.¿Qué diferencia hay entre
SubjectyBehaviorSubject?4.Tienes
busqueda$.pipe(switchMap((t) => api.buscar(t)), catchError(() => of([]))). Tras el primer error, el buscador deja de responder. ¿Por qué?5.Necesitas cargar en paralelo el perfil y los pedidos del usuario, y pintar la pantalla cuando hayan llegado los dos. ¿Qué usas?