Cuándo usar RxJS o Signals en Angular: el criterio real
El mes pasado, en un code review, me encontré esto:
// user.service.ts
private readonly userSubject = new BehaviorSubject<User | null>(null);
readonly user$ = this.userSubject.asObservable();
setUser(user: User) {
this.userSubject.next(user);
}
Nadie combinaba ese observable con nada. Nadie lo cancelaba. Solo se consumía con async en dos plantillas. Es un signal con tres pasos de más.
Dos archivos más abajo estaba el problema espejo:
readonly productos = signal<Product[]>([]);
readonly cargando = signal(false);
readonly error = signal<string | null>(null);
private peticionEnCurso = 0; // para descartar respuestas viejas
Eso ya no es estado. Es un observable reimplementado a mano, y mal.
Le pregunté al dev qué criterio había usado. Me dio el mismo que da todo el mundo cuando le preguntas cuándo usar RxJS o Signals en Angular: "Signals para estado, RxJS para async".
La regla es correcta. Yo también la he escrito. Y no le sirvió de nada, porque los dos fragmentos que acabas de ver la cumplen.
Cuándo usar RxJS o Signals en Angular: la regla que no decide nada
"Estado" y "async" no describen tu código. Describen categorías. Y tu código real vive justo en la frontera entre las dos.
Un autocompletado con debounceTime que además guarda el producto elegido: ¿eso es estado o es async? Las dos cosas. Un WebSocket de precios que solo se pinta en pantalla: es async por definición, y sin embargo la UI solo quiere el último valor. Una cancelación de peticiones que ayer era switchMap y hoy podría ser un resource.
En todos esos casos la regla te deja exactamente donde estabas: eligiendo por costumbre.
El criterio que sí decide: ¿necesitas el valor o la secuencia?
Signals te dan el valor actual. Qué vale esto ahora, en este instante, cuando alguien lo lee.
RxJS te da los eventos en el tiempo. Qué pasó, en qué orden, cuántas veces, y qué hacer si llega otro antes de que termine el anterior.
Esa es toda la decisión:
- Si el cuándo y el en qué orden forman parte de tu lógica, es RxJS.
- Si solo importa qué vale ahora, es un signal.
Aplícalo a los dos fragmentos del principio y se resuelven solos. El BehaviorSubject del usuario nunca pregunta cuántas veces cambió ni en qué orden: quiere el valor actual. Es un signal. Y el signal() rodeado de cargando, error y peticionEnCurso está preguntando exactamente eso —cuál llegó primero, cuál descarto— con las manos. Es un flujo.
La pista más fiable es esta: en cuanto necesitas una variable auxiliar para saber qué pasó antes, has salido del territorio de los signals.
Tabla de decisión: RxJS o Signals, caso por caso
| Necesitas… | Elige | Por qué |
|---|---|---|
| El valor actual para pintarlo | signal |
Solo importa qué vale ahora |
| Derivar un valor de otros | computed |
Sin suscripciones ni sincronización manual |
| Que el orden de llegada cambie el resultado | RxJS | El tiempo es parte de la lógica |
| Esperar a que el usuario deje de escribir | RxJS (debounceTime) |
Los signals no tienen noción de tiempo |
| Reaccionar a cada evento, uno a uno | RxJS | El grafo de signals colapsa valores intermedios |
| Descartar la respuesta anterior al llegar otra | switchMap o un resource |
Cancelación a mano o incluida |
| Guardar lo que el usuario eligió | signal |
Es estado, no un evento |
| Un push del servidor que solo se pinta | toSignal() del stream |
La secuencia se procesa fuera; el valor entra al grafo |
Esta tabla decide. No ejecuta. Cuando ya sepas qué mover, el cómo —los patrones de refactorización, uno a uno— está en la guía de migración de RxJS a Signals.
Frontera 1: el autocompletado que además guarda la selección
El caso que rompe la regla. Hay tiempo (debounce, cancelación) y hay estado (el término, la selección).
No elijas. Parte el componente por la mitad:
readonly term = signal(39;39;); // valor
readonly selected = signal<Product | null>(null); // valor
private readonly results$ = toObservable(this.term).pipe( // secuencia
debounceTime(300),
distinctUntilChanged(),
switchMap((t) => this.http.get<Product[]>(`/api/products?q=${encodeURIComponent(t)}`)),
);
readonly results = toSignal(this.results$, { initialValue: [] }); // vuelve a valor
choose(p: Product) {
this.selected.set(p);
this.term.set(p.name);
}
Estado a los lados, secuencia en el medio. El pipeline no guarda nada y los signals no saben nada del tiempo.
Este reparto —qué vive en el grafo y qué vive en el pipeline— es la base de la arquitectura de señales que trabajo a fondo en el curso de Angular Moderno. Y cómo se ordena a escala de aplicación entera —servicios, stores, componentes— lo desarrollé en la arquitectura moderna de Angular con Signals.
Frontera 2: el WebSocket que alimenta la UI
Aquí todo el mundo asume RxJS y se queda ahí. La mitad de la respuesta es correcta.
La conexión y el filtrado son secuencia pura: RxJS. Pero lo que la plantilla consume es un valor.
private readonly ticks$ = webSocket<Tick>(39;wss://api.example.com/ticks').pipe(
filter((t) => t.symbol === 39;BTC39;),
);
readonly lastTick = toSignal(this.ticks$, { initialValue: null });
toSignal() se desuscribe solo cuando se destruye el componente o el servicio que lo crea, salvo que le pases manualCleanup. No hay takeUntil, ni async repetido en la plantilla, ni ngOnDestroy.
Y si tu lógica necesita cada tick —contarlos, agruparlos por ventana, acumularlos— eso se queda en el pipeline. No lo subas al signal.
Frontera 3: cancelar peticiones, switchMap o un resource
Este es el que más ha cambiado.
switchMap cancela la petición anterior cuando llega un valor nuevo. Sigue siendo la respuesta correcta si en el mismo flujo hay debounce, reintentos con backoff o una combinación de varias fuentes.
Pero si lo único que haces es "cuando cambia este parámetro, vuelve a pedir y descarta lo anterior", ya no necesitas escribir el flujo. httpResource es un envoltorio sobre HttpClient que te da el estado de la petición y la respuesta como señales, pasando por los interceptors. Y rxResource acepta una propiedad stream con una función que devuelve un Observable, para cuando la fuente ya la tienes en RxJS:
readonly detail = rxResource({
params: () => this.selectedId(),
stream: ({ params }) => this.http.get<Product>(`/api/products/${params}`),
});
Un detalle de la documentación que cuesta caro descubrir en producción: ese Observable debe emitir un valor o un error antes de completar. Si le encadenas un catchError(() => EMPTY) —o un filter que a veces no deja pasar nada— y el Observable completa sin emitir, Angular lanza NG0991, Resource completed before producing a value. No se queda esperando: revienta.
Frontera 4: dónde va el efecto secundario, effect() o tap()
La diferencia no es de estilo. Es de garantías.
tap() se ejecuta una vez por evento, en un punto conocido del orden, y ve el evento.
effect() se ejecuta cuando el valor acaba siendo algo. No te garantiza que veas todos los intermedios: el grafo de señales agrupa actualizaciones y ejecuta el efecto sobre el resultado ya estabilizado. Si alguna vez te has preguntado por qué un computed no se recalcula cuando esperabas, la explicación está en cómo funciona el grafo reactivo de Angular por dentro.
La regla práctica:
- Analítica de "el usuario pulsó el botón" →
tap(). Cada pulsación cuenta. - "Cuando el carrito quede vacío, oculta el panel" →
effect(). Da igual cómo llegó a estar vacío.
Y si el efecto es calcular otro valor, no es un efecto. Es un computed. Escribir signals dentro de un effect es la forma más rápida de construir un grafo que nadie entiende y que ningún test cubre.
La frontera exacta de toSignal()
toSignal() es el punto donde una secuencia se convierte en valor. Vive en @angular/core/rxjs-interop y necesita un contexto de inyección, o que le pases un Injector.
Tres cosas que la documentación dice y casi nadie lee.
Se suscribe inmediatamente. La doc lo avisa: "Like the async pipe, toSignal subscribes to the Observable immediately, which may trigger side effects". Si tu observable dispara una petición al suscribirse, esa petición sale ya.
No lo llames dos veces. "You should avoid calling it repeatedly for the same Observable, and instead reuse the signal it returns". Cada llamada es una suscripción nueva. Dos toSignal() sobre el mismo stream son dos conexiones.
Los errores se lanzan al leer. No al emitir: al leer el signal. Y si el observable completa, el signal se queda con el último valor emitido, no se vacía.
Traducido: toSignal() es una frontera de salida, y se cruza una sola vez, lo más cerca posible de la plantilla.
Cuándo toObservable() te avisa de que vas al revés
toObservable() es legítimo, y lo has visto arriba: tienes un signal de input y necesitas operadores de tiempo. Estado que se convierte en secuencia.
El problema es el otro uso, el de volver a RxJS por inercia. Y tiene una trampa medible.
toObservable() está implementado con un effect y un ReplaySubject. Eso significa que agrupa varias actualizaciones del signal en una sola emisión. Si actualizas el signal tres veces seguidas, no vas a recibir tres valores.
Así que si tu pipeline necesita ver cada cambio, toObservable() no te lo va a dar. Y si no necesita verlos, probablemente no necesitabas el pipeline.
Dos señales de que has girado en dirección equivocada: usarlo para combinar dos signals —eso es un computed— o usarlo para ejecutar algo cuando cambia un valor, que es un effect.
toSignal() debería aparecer varias veces en tu código. toObservable(), muy pocas, y cada una con un operador de tiempo detrás que lo justifique.
Qué hacer hoy en tu proyecto Angular
Abre tu proyecto y busca signals con una variable auxiliar al lado: un flag cargando, un contador de peticiones, un ultimoId. Cada una de esas variables es la secuencia que el signal no sabe representar. Ahí tienes un flujo pidiendo volver a RxJS.
Luego busca lo contrario, que cuesta menos: cada BehaviorSubject del que solo se lee el último valor. Si nadie depende del orden en que llegaron —y en un .asObservable() consumido con async casi nunca se depende—, tienes un signal esperando a salir.
Los dos del principio de este post salen así: el BehaviorSubject del usuario son tres líneas —subject privado, observable público y setter— que se quedan en una, signal<User | null>(null). Y los cuatro campos del segundo caso —productos, cargando, error y peticionEnCurso— se quedan en un resource. Puedes contarlos tú en los snippets de arriba.
Diez minutos. Y decides con el criterio, no con la moda.
Y si quieres discutir estas decisiones sobre proyectos reales, y no sobre listas de tareas, es justo lo que hacemos en Dominicode Labs.
Preguntas frecuentes
¿Cuándo usar RxJS o Signals en Angular?
Pregúntate si necesitas el valor o la secuencia. Los signals te dan el valor actual: qué vale algo ahora mismo. RxJS te da los eventos en el tiempo: qué pasó, en qué orden y qué hacer si llega otro antes de terminar el anterior. Si el cuándo y el orden forman parte de tu lógica, es RxJS. Si solo importa el valor actual, es un signal. La pista más fiable es que necesites una variable auxiliar para recordar qué pasó antes: ahí ya has salido del territorio de los signals.
¿Sigue teniendo sentido RxJS ahora que existen los signals?
Sí, y no por compatibilidad hacia atrás. Los signals no tienen ninguna noción de tiempo: no saben esperar, ni descartar, ni contar emisiones, ni reintentar con backoff. Nada de eso lo cubre el grafo reactivo. Mientras tu lógica dependa de la secuencia —debounce, WebSockets, coordinación de varias fuentes asíncronas, cancelación combinada con otros operadores— RxJS es la herramienta correcta y no hay sustituto.
¿Debo reemplazar todos mis BehaviorSubject por signals?
Todos no. Reemplaza los que solo mantienen el último valor y se consumen con la pipe async o con un subscribe que hace un set. Esos son signals con pasos de más. Deja los que participan en un flujo real: los que se combinan con otros streams, los que alimentan un pipeline con operadores de tiempo o los que representan eventos en los que el orden importa. El criterio no es el tipo, es qué haces con él.
¿Cuál es la diferencia entre effect() y tap() para efectos secundarios?
tap() se ejecuta una vez por evento, en un punto conocido del flujo, y recibe el evento. effect() se ejecuta cuando el valor acaba estabilizándose, y no garantiza que veas los estados intermedios porque el grafo agrupa actualizaciones. Para "el usuario pulsó tres veces" necesitas tap(). Para "cuando esto quede vacío, oculta el panel" necesitas effect(). Y si lo que quieres es calcular otro valor, el effect sobra: eso lo resuelve un computed.
¿Es mala señal usar toObservable() en mi código?
Depende de en qué dirección vayas. Si conviertes un signal de estado en secuencia para aplicarle operadores de tiempo, es exactamente para lo que existe. Si lo usas para combinar dos signals o para reaccionar a un cambio, te estás alejando: eso son computed y effect. Además tiene una consecuencia práctica que conviene conocer: agrupa varias actualizaciones del signal en una sola emisión, así que no verás todos los valores intermedios.
Comportamiento verificado contra la documentación de Angular 22 en septiembre de 2026.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
