El listener middleware de RTK: la alternativa moderna a saga
createListenerMiddleware de RTK hace una apuesta afilada: casi todo lo que la gente pedía a saga —reaccionar a una acción, esperar a otra, cancelar la ejecución anterior, agrupar ráfagas— no necesitaba generadores ni efectos-como-dato, solo un buen vocabulario de suscripción y cancelación expresado en el async/await que ya conoces. Esta lección registra oyentes por actionCreator, matcher y predicate, usa el listenerApi para esperar el futuro con condition y take, logra debounce y take-latest con cancelActiveListeners y delay, explica por qué el listener corre tras el reducer, y sitúa la herramienta como la síntesis del nivel: la coreografía de saga para el ochenta por ciento de los casos, sin un solo function*.
El listener middleware de RTK hace una apuesta afilada: casi todo lo que la gente pedía a saga —reaccionar a una acción, esperar a otra, cancelar la ejecución anterior, agrupar las ráfagas— no necesitaba generadores ni efectos-como-dato, sino solo un buen vocabulario de suscripción y cancelación expresado en el async/await que ya dominas. createListenerMiddleware te deja registrar oyentes con la forma “cuando ocurra esta acción, corre este efecto”, y el listenerApi que recibe cada efecto trae las piezas que hacían potente a saga —take, condition, fork, cancelación cooperativa— sin un solo function*. Es la alternativa moderna: la coreografía de saga para el ochenta por ciento de los casos, escrita como promesas normales. Y reubica la lógica reactiva en su hogar correcto —ni en los componentes, ni en el reducer— como un registro declarativo de reacciones al flujo de acciones.
- Registrar oyentes con
startListeningporactionCreator,matcheropredicate. - Usar el
listenerApipara esperar acciones futuras conconditionytake, sin generadores. - Lograr debounce y “quédate con la última” con
cancelActiveListenersydelay. - Entender por qué el listener corre tras el reducer y cuándo sustituye a saga.
Suscribirse al flujo de acciones
Creas el middleware con createListenerMiddleware, lo cableas en configureStore con prepend —para que vea las acciones antes que las guardas por defecto— y registras oyentes con startListening. Cada oyente declara cuándo dispara y qué hace. El cuándo admite tres formas de precisión creciente: actionCreator para un tipo exacto, matcher para un grupo —los isAnyOf o los matchers de createAsyncThunk—, y predicate para una condición arbitraria sobre la acción y el estado, que es lo que te deja disparar cuando un valor derivado cruza un umbral, no solo cuando llega un tipo.
import { createListenerMiddleware, addListener } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "./store";
import { login } from "./sesionSlice";
import { registrarAnalitica } from "./analitica";
export const listenerMiddleware = createListenerMiddleware();
// startListening tipado: conoce RootState y AppDispatch en cada effect
const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>();
startAppListening({
actionCreator: login,
effect: async (action, api) => {
// corre despues de que el reducer ya aplico el login
api.dispatch(registrarAnalitica("login", action.payload.id));
},
});
A diferencia de un middleware normal, que intercepta la acción antes de que llegue al reducer, el listener middleware primero deja pasar la acción —el reducer actualiza el estado— y solo entonces evalúa los oyentes contra el resultado. Por eso dentro de un effect, api.getState() devuelve el estado ya modificado por la acción, y api.getOriginalState() devuelve el de justo antes. Tener ambos a mano es lo que hace tan natural el predicate que compara “valor antes” con “valor después” para disparar solo en el flanco de un cambio, como cuando el total del carrito cruza el umbral del envío gratis.
Esperar el futuro sin generadores
Aquí es donde el listener alcanza a saga. El listenerApi trae condition, que pausa el efecto hasta que se cumple un predicado sobre una acción o el estado futuros; take, que espera una acción concreta y devuelve la tupla [accion, estado]; delay, para temporizar; y cancelActiveListeners, que mata las ejecuciones anteriores de este mismo oyente. Con esas piezas, el debounce y el “quédate con la última” —que costaban contabilidad manual en thunk y una palabra en saga— se escriben en async/await directo.
import { buscar, consultar, mostrar } from "./busquedaSlice";
startAppListening({
actionCreator: buscar,
effect: async (action, api) => {
api.cancelActiveListeners(); // mata la ejecucion previa: esto es take-latest
await api.delay(300); // debounce; si llega otra busqueda antes, esta se cancela aqui
const resultados = await api.dispatch(consultar(action.payload)).unwrap();
api.dispatch(mostrar(resultados));
},
});
startAppListening({
actionCreator: abrirAsistente,
effect: async (action, api) => {
// take: pausa hasta que llegue aceptar o cancelar; el mismo handshake de saga
const [confirmado] = await api.take(
(a) => aceptar.match(a) || cancelar.match(a),
);
if (aceptar.match(confirmado)) await api.dispatch(guardar()).unwrap();
},
});
Un detalle recurrente en esos efectos es unwrap. Cuando despachas un createAsyncThunk desde un listener, dispatch(thunk()) devuelve una promesa que siempre se resuelve —lleva dentro el resultado o el error como datos, nunca lanza—; encadenar .unwrap() la convierte en una promesa que resuelve con el valor o lanza el error, para que uses try/catch normal dentro del efecto. Así el listener orquesta thunks —espera a que uno acabe, decide según su resultado, dispara el siguiente— combinando lo mejor de ambos escalones: el thunk hace el recado, el listener coordina la conversación entre recados.
Cada efecto corre aislado. Un rechazo no capturado dentro de un oyente lo atrapa el middleware y lo registra, pero no se propaga ni al dispatch que lo disparó ni a los oyentes hermanos, a diferencia de un middleware normal, donde un throw reventaría el dispatch entero. Esa isla es cómoda —una analítica que falla no tumba tu flujo— pero tiene un filo: los oyentes son de disparar-y-olvidar, así que nadie aguas arriba va a manejar tus errores por ti. Envuelve el trabajo frágil en try/catch dentro del propio efecto y decide ahí qué hacer, porque el silencio del middleware es la única red que hay.
flowchart LR D[accion despachada] --> R[reducer actualiza el estado] D --> L[listener evalua predicados] L -->|coincide| E[corre el effect async] E -->|dispatch| D E -.cancelActiveListeners.-> X[cancela ejecuciones previas] style R fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b style X fill:#f38ba8,color:#11111b
La alternativa moderna a saga
El listener se sitúa exactamente entre el thunk y la saga. Sobre el thunk gana la suscripción: no es un efecto de un solo tiro que despachas una vez, sino un oyente permanente que reacciona a cada aparición de una acción, y transversal, porque una rebanada puede reaccionar a las acciones de otra sin acoplarse a ella. Sobre la saga gana la familiaridad: fork, take, cancelación y debounce sin generadores ni la indirección de efectos-como-dato, porque aquí sí ejecutas el efecto —es await fetch(...) de verdad—, no describes uno. Además es dinámico: puedes añadir y quitar oyentes en tiempo de ejecución con dispatch(addListener(...)) y la baja que retorna, lo que encaja con el code splitting.
Visto desde el nivel 3, el listener es el patrón Observer llevado a su conclusión natural: el flujo de acciones es el sujeto observable de toda la aplicación, y cada oyente es un observador que reacciona a él con lógica arbitraria y ciclo de vida propio. Donde RxJS te daría operadores sobre un stream —debounceTime, switchMap—, el listener te ofrece sus equivalentes imperativos —delay, cancelActiveListeners— sin obligarte a adoptar el álgebra entera de los observables. Es reactividad sobre el mismísimo stream que ya alimenta al reducer, sin una segunda fuente de verdad que mantener sincronizada: el mismo hecho que actualiza el estado dispara el efecto, y ambos leen de la única corriente de acciones.
predicate
Decide cuándo dispara mirando la acción y el estado, antes y después; permite reaccionar a valores derivados, no solo a tipos.
condition y take
Pausan el efecto esperando una acción o un estado futuros; convierten secuencias y handshakes en código en línea recta.
cancelActiveListeners
Cancela las corridas anteriores de este oyente: el “quédate con la última” de saga, en una llamada.
fork
Lanza tareas hijas concurrentes con resultado y cancelación propios, para trabajo en segundo plano sin bloquear el efecto.
Igual que con el thunk, resístete a usar el listener para leer del servidor y cachear a mano. Su terreno es la lógica reactiva de cliente: analítica, sincronización entre rebanadas, persistir una preferencia al cambiar, disparar un efecto cuando un derivado cruza un umbral. La caché de datos remotos sigue siendo de RTK Query o TanStack Query. El listener brilla reaccionando a tu propio flujo de acciones, no supliendo una capa de datos que ya está resuelta con mejores garantías en otra parte.
El listener middleware es la conclusión hacia la que empujaba todo este nivel, porque toma las mejores ideas de saga —suscribirse al flujo de acciones, esperar acciones futuras, cancelar, bifurcar— y suelta lo único que hacía a saga difícil: los generadores y la indirección de reificar cada efecto como un dato. Es una apuesta explícita sobre la naturaleza del trabajo real: el noventa por ciento de lo que los equipos pedían a saga —debounce, quédate-con-la-última, espera-entonces-actúa, reacciones entre rebanadas— necesitaba el vocabulario de cancelación y secuencia, pero no la testabilidad-por-reificación que los generadores compraban a cambio de su curva. Para ese noventa por ciento, el listener es ergonomía estrictamente mejor; para el diez restante —orquestación densísima, crítica en pruebas, profundamente dirigida por eventos— la cosificación de saga aún justifica su precio. Pero su aportación más honda no es técnica sino topológica: reubica la lógica reactiva en su hogar legítimo. No en los componentes, donde queda dispersa y muere con el montaje; no en los thunks, que son de un solo disparo y no se suscriben; no en el reducer, que ha de seguir puro; sino en un registro declarativo que dice “cuando la aplicación haga X, haz también Y” —que es, punto por punto, la forma de un efecto bien diseñado—. Dominarlo es menos cuestión de su API que de interiorizar que la inmensa mayoría de los efectos son reacciones al flujo de acciones, y que lo correcto es suscribirse al flujo, no al componente que casualmente lo disparó.
- Crea el middleware, cabléalo con
prependy registra un oyente poractionCreatorque despache una acción de analítica; confirma congetStatefrente agetOriginalStateque corre tras el reducer. - Construye una búsqueda con debounce:
cancelActiveListenersmásdelay(300)yunwrapde un thunk; teclea rápido y verifica que solo dispara la última. - Usa un
predicateque compare estado antes y después para disparar solo cuando el total del carrito cruza el umbral del envío gratis, no en cada cambio. - Modela el diálogo de confirmación con
api.take([...])y ramifica: el mismo handshake que escribiste en saga, ahora enasync/await. - Añade y quita un oyente en caliente con
dispatch(addListener(...))y la baja que retorna; confirma que deja de reaccionar tras darlo de baja. - Reescribe con
conditionuna espera por estado —no sigas hasta que el carrito tenga al menos un ítem— y comprueba que el efecto se reanuda solo cuando la condición se cumple. - Encadena dos thunks en un listener con
unwrapytry/catch: dispara el segundo solo si el primero tuvo éxito y registra el error si el primero falla.