En proyectos Angular me he encontrado varias veces con el mismo
planteamiento: una pantalla tiene que reaccionar a cambios en un
buscador, unos filtros o la paginación, y esa lógica está repartida
entre varios effect().
No es un enfoque con el que esté de acuerdo. Entiendo por qué se llega ahí: al principio parece cómodo. Cada efecto observa una señal y ejecuta una operación cuando cambia. El problema aparece cuando hay que entender el flujo completo: qué dispara una petición, qué valores utiliza, qué pasa si cambian varios criterios seguidos y dónde se actualiza el estado.
Cuando reviso o refactorizo código, una de las cosas que más valoro es poder entender lo que hace sin tener que reconstruir mentalmente una cadena de efectos secundarios.
Vamos a verlo con un ejemplo sencillo.
Signals y effect(), en pocas palabras
Signals llegaron en Angular 16, primero como developer preview. Son una forma de representar estado reactivo y derivar valores a partir de él.
Una señal contiene un valor que podemos leer y actualizar:
readonly page = signal(1);
this.page.set(2);
console.log(this.page()); // 2
Un computed() deriva un valor de otras señales:
readonly price = signal(100);
readonly taxRate = signal(0.21);
readonly totalPrice = computed(
() => this.price() * (1 + this.taxRate()),
);
Un effect() ejecuta una función una vez al crearse y otra cada vez que
cambian las señales que lee:
effect(() => {
console.log('Página actual:', this.page());
});
Conviene distinguir bien las tres piezas. Una señal representa estado;
computed() deriva estado; effect() ejecuta efectos secundarios
cuando ese estado cambia.
Un efecto tiene sentido, por ejemplo, para sincronizar una señal con una API externa imperativa. Lo que quiero tratar aquí es otra cosa: usar efectos como mecanismo general para coordinar cambios de estado, peticiones y otras reacciones entre sí.
RxJS: describir lo que ocurre a lo largo del tiempo
RxJS trabaja con Observables: fuentes que emiten valores a lo largo del tiempo. Permite transformar esos valores, combinarlos, filtrarlos y coordinar operaciones asíncronas mediante operadores.
Para el ejemplo nos bastan unos pocos:
combineLatest: combina los valores más recientes de varias fuentes.debounceTime(300): espera 300 ms sin nuevas emisiones antes de dejar pasar el valor.distinctUntilChanged(): descarta valores consecutivos iguales.switchMap: cambia al observable nuevo y se desuscribe del anterior.
Y en la plantilla, el pipe async, que se suscribe al observable y se
desuscribe cuando el componente se destruye.
La diferencia práctica es que podemos describir el flujo en un solo sitio: qué valores entran, cómo se combinan y qué operación se ejecuta después.
El problema: una tabla con buscador y paginación
Imaginemos una pantalla de administración de usuarios con:
- Un campo de búsqueda.
- Un filtro por estado.
- Un control de paginación.
- Una tabla que muestra los resultados del servidor.
Cuando cambia cualquiera de esos criterios, queremos pedir los datos correspondientes usando la combinación actual de búsqueda, página y filtro.
El antipatrón: repartir la carga entre varios effect()
Una primera aproximación podría ser esta:
readonly search = signal('');
readonly page = signal(1);
readonly status = signal('all');
constructor() {
effect(() => {
this.search();
this.loadUsers();
});
effect(() => {
this.page();
this.loadUsers();
});
effect(() => {
this.status();
this.loadUsers();
});
}
El código es intencionadamente sencillo, pero ya tiene varios problemas.
El primero es de lectura. La lógica que coordina búsqueda, paginación y
filtro queda repartida entre tres puntos de entrada. Para saber qué
ocurre cuando cambia un criterio, tenemos que recordar qué hace cada
efecto y cómo se relaciona con loadUsers().
El segundo es menos evidente. loadUsers() necesita leer las tres
señales para construir la petición, y un efecto registra como
dependencia cualquier señal que se lea durante su ejecución, también
dentro de los métodos a los que llama. Así que, en la práctica, cada
efecto depende de las tres señales. Al cargar la pantalla se lanzan tres
peticiones, y cada vez que cambia un criterio, otras tres.
Se puede arreglar con untracked(), pero eso ya es una pista: estamos
añadiendo piezas para controlar los efectos en lugar de describir lo que
queremos que pase.
Y queda el tema de las peticiones concurrentes. Si loadUsers() lanza
una suscripción nueva en cada llamada sin cancelar las anteriores, una
respuesta antigua puede llegar tarde y pisar resultados más recientes.
Podríamos juntarlo todo en un único efecto, añadir condiciones o señales auxiliares. Seguiríamos sin debounce y sin cancelación, y el flujo no sería más fácil de seguir.
El problema no es que effect() exista ni que todo efecto sea
incorrecto. Es usarlo como sustituto de un flujo reactivo que tiene
varias entradas y operaciones asíncronas que coordinar.
Una alternativa: describir el flujo con RxJS
Podemos modelar la búsqueda, la página y el estado como fuentes de valores, combinarlas y hacer una petición cada vez que cambie la combinación.
En el ejemplo uso un FormControl para la búsqueda y BehaviorSubject
para la página y el filtro:
import { FormControl } from '@angular/forms';
import {
BehaviorSubject,
combineLatest,
debounceTime,
distinctUntilChanged,
startWith,
switchMap,
} from 'rxjs';
readonly searchControl = new FormControl('', {
nonNullable: true,
});
readonly page$ = new BehaviorSubject(1);
readonly status$ = new BehaviorSubject('all');
readonly search$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
startWith(this.searchControl.value),
distinctUntilChanged(),
);
readonly users$ = combineLatest([
this.search$,
this.page$.pipe(distinctUntilChanged()),
this.status$.pipe(distinctUntilChanged()),
]).pipe(
switchMap(([search, page, status]) =>
this.usersService.getUsers({ search, page, status }),
),
);
En la plantilla consumimos el resultado con async:
@if (users$ | async; as users) {
<app-users-table [users]="users" />
}
El ejemplo presupone que getUsers() devuelve un Observable con los
usuarios. En una aplicación real faltaría el estado de carga, el
tratamiento de errores y tipar bien el filtro.
¿Qué está pasando aquí?
1. Combinamos los últimos valores
combineLatest guarda el último valor de cada fuente. Cuando todas han
emitido al menos una vez, cada cambio genera una nueva combinación.
Los BehaviorSubject emiten su valor nada más suscribirnos, pero
valueChanges no emite hasta que el usuario escribe. Por eso añadimos
startWith: sin él, la tabla no se cargaría al entrar en la pantalla.
2. No pedimos datos por cada tecla
debounceTime(300) espera a que el usuario deje de escribir 300 ms
antes de dejar pasar el valor. Lo ponemos antes de startWith para que
la carga inicial no tenga que esperar esos 300 ms.
3. Evitamos peticiones repetidas
distinctUntilChanged() descarta emisiones consecutivas iguales. Si el
usuario escribe una letra y la borra antes de que pase el debounce, no
se lanza una petición idéntica a la anterior.
4. Nos quedamos con la petición más reciente
switchMap cambia al observable de la combinación más reciente y se
desuscribe del anterior.
Si getUsers() usa el HttpClient de Angular, esa desuscripción
cancela también la petición HTTP en curso. Con otras fuentes depende:
desuscribirse no siempre detiene la operación que hay detrás. Lo que sí
garantiza switchMap es que la respuesta antigua ya no llega a la
tabla.
Un detalle de comportamiento: ¿qué pasa con la página al buscar?
Hay una decisión de producto que conviene tomar de forma explícita: cuando el usuario cambia la búsqueda o el filtro, ¿mantenemos la página actual o volvemos a la primera?
Si el usuario está en la página 8 y aplica un filtro que solo tiene una página de resultados, mantener la página 8 deja la tabla vacía. Muchas interfaces lo resuelven volviendo a la primera página.
No conviene esconder esa decisión dentro de un efecto. Es mejor que esté en la acción que cambia el criterio:
onStatusChanged(status: string): void {
this.status$.next(status);
this.page$.next(1);
}
Aquí combineLatest emite dos veces seguidas (primero con el nuevo
filtro y la página antigua, luego con la página 1), pero switchMap
descarta la primera petición y nos quedamos con la buena.
Con la búsqueda hay que tener un poco más de cuidado: si reiniciamos la
página en cada pulsación, la página cambia antes de que pase el
debounce y lanzamos una petición con el texto antiguo. Lo razonable es
reiniciar la página cuando llega el valor ya filtrado por el
debounceTime, no con cada tecla.
Lo importante es que la regla esté definida a propósito y no aparezca como efecto colateral de cómo se encadenan los efectos.
Lo que ofrece Angular hoy: resource() y rxResource()
Angular tiene una pieza pensada justo para este caso: las APIs de resource. Aparecieron como experimentales en la versión 19 y en la 22 pasaron a ser estables.
La idea es sencilla: le dices de qué señales depende la petición y cómo se hace, y Angular se encarga del resto. Cuando cambian esas señales, relanza la petición, descarta la anterior y te da el resultado, el estado de carga y el error como señales.
Hay tres variantes:
resource(): la petición es una función asíncrona (unaPromise).rxResource(): la petición devuelve unObservable.httpResource(): la petición se describe directamente como una llamada HTTP, sobreHttpClient.
Como nuestro getUsers() ya devuelve un Observable, usamos
rxResource(). El mismo ejemplo quedaría así:
import { signal } from '@angular/core';
import { rxResource, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { debounceTime, distinctUntilChanged } from 'rxjs';
readonly searchControl = new FormControl('', {
nonNullable: true,
});
readonly search = toSignal(
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
),
{ initialValue: this.searchControl.value },
);
readonly page = signal(1);
readonly status = signal('all');
readonly users = rxResource({
params: () => ({
search: this.search(),
page: this.page(),
status: this.status(),
}),
stream: ({ params }) => this.usersService.getUsers(params),
});
onStatusChanged(status: string): void {
this.status.set(status);
this.page.set(1);
}
Y en la plantilla:
@if (users.isLoading()) {
<p>Cargando…</p>
}
@if (users.error()) {
<p>No se han podido cargar los usuarios.</p>
}
@if (users.hasValue()) {
<app-users-table [users]="users.value()" />
}
Lo que antes resolvíamos con combineLatest y switchMap ahora está en
params y stream:
paramsdice de qué depende la petición. Es lo que los treseffect()del principio intentaban expresar, pero en un solo sitio.streamdice cómo se hace la petición. Siparamscambia mientras hay una en curso, Angular se queda con la nueva, igual queswitchMap.- Ya no hace falta
distinctUntilChanged()en la página ni en el filtro: si a una señal le asignas el mismo valor que ya tenía, no notifica ningún cambio. - El cambio de filtro y el reinicio de página se hacen seguidos, y
paramsse recalcula una sola vez, así que sale una sola petición. - El estado de carga y el error vienen incluidos, cosa que en la versión con RxJS tendríamos que montar a mano.
La búsqueda sigue usando RxJS para el debounce: el FormControl emite
un observable, lo esperamos 300 ms y lo convertimos en señal con
toSignal(). Angular 22 trae también una función debounced() para
hacer esto directamente con señales, pero de momento es experimental.
Si getUsers() no hiciera más que una llamada a HttpClient,
httpResource() permitiría ahorrarse el servicio y describir la
petición directamente en el componente.
¿Y qué pasa con Signals?
Signals y RxJS no tienen por qué competir. Resuelven necesidades relacionadas, pero distintas.
Signals funcionan muy bien para representar el estado actual y derivar valores. RxJS brilla cuando hay que describir eventos y operaciones asíncronas en el tiempo: combinar entradas, esperar, transformar emisiones o cambiar de petición.
Y se pueden combinar. Si la página y el filtro ya son señales en tu
componente, toObservable() las convierte en observables para meterlas
en el mismo flujo. En sentido contrario, toSignal() permite consumir
el resultado como una señal desde la plantilla o desde código basado en
Signals.
¿Significa rxResource() que ya no hace falta RxJS? Para este caso,
cargar datos que dependen del estado de la pantalla, yo empezaría hoy
por un resource. Es lo que Angular propone y el código queda más
corto.
RxJS sigue teniendo sentido cuando lo que hay que modelar es un flujo de
eventos y no una petición que depende de un estado: un websocket,
reintentos con espera, polling, combinar eventos de usuario o
cualquier lógica en la que el tiempo importa. Y rxResource() no está
reñido con eso: dentro de stream se pueden seguir usando operadores.
Mi criterio es práctico: si el problema consiste en coordinar varias
fuentes de cambios y operaciones asíncronas, prefiero que ese flujo esté
escrito de forma explícita, ya sea con un resource o con RxJS. Cuando para entender por qué se lanza una
petición hay que saltar entre varios effect(), suele ser buena señal
de que conviene revisar el diseño.
La mantenibilidad también importa
En una refactorización no busco únicamente reducir líneas de código. Busco que la intención sea visible.
Cuando leo esto:
combineLatest([search$, page$, status$]).pipe(
switchMap(([search, page, status]) =>
this.usersService.getUsers({ search, page, status }),
),
);
veo rápido las entradas y la operación que se ejecuta. Y si quiero saber
cómo se comporta la búsqueda, voy a search$ y ahí está el debounce.
Eso no significa que RxJS sea siempre lo más sencillo. Un flujo con demasiados operadores o anidamientos también se vuelve difícil de mantener. La claridad no viene de elegir una API concreta, sino de modelar el problema de forma que sus reglas sean fáciles de seguir.
Pero cuando estamos coordinando búsqueda, filtros, paginación y peticiones al servidor, prefiero expresar esas relaciones como un flujo antes que repartirlas entre efectos que se disparan unos a otros.
Para terminar
Me he encontrado este patrón en más de una ocasión: usar effect() para
coordinar cambios que se entenderían mejor como un flujo reactivo
explícito.
No creo que la solución sea sustituir todos los efectos por RxJS. Creo que merece la pena preguntarse qué estamos intentando modelar y escoger la herramienta que lo exprese con más claridad.
Si al leer un componente necesitas reconstruir mentalmente qué efecto dispara a otro y qué estado acaba cambiando, quizá el problema no sea el número de líneas, sino cómo está representada la reactividad.
A veces, una buena refactorización empieza por algo tan sencillo como separar responsabilidades y hacer visible el flujo de datos.