wandres.dev
MIDDLEWARE Y EFECTOS · thunk, saga, listener

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*.

⏱ 17 min

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.

🎯 Al terminar esta lección sabrás
  • Registrar oyentes con startListening por actionCreator, matcher o predicate.
  • Usar el listenerApi para esperar acciones futuras con condition y take, sin generadores.
  • Lograr debounce y “quédate con la última” con cancelActiveListeners y delay.
  • 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));
  },
});
📝
El listener corre después del reducer, no antes

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.

⚠️
Un listener que falla no rompe a los demás

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.

💡
Deja el estado de servidor fuera de aquí

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 es la síntesis del nivel: suscribirse al flujo, no al componente

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ó.

⚔️ Reacciona al flujo, no al render
  1. Crea el middleware, cabléalo con prepend y registra un oyente por actionCreator que despache una acción de analítica; confirma con getState frente a getOriginalState que corre tras el reducer.
  2. Construye una búsqueda con debounce: cancelActiveListeners más delay(300) y unwrap de un thunk; teclea rápido y verifica que solo dispara la última.
  3. Usa un predicate que 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.
  4. Modela el diálogo de confirmación con api.take([...]) y ramifica: el mismo handshake que escribiste en saga, ahora en async/await.
  5. 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.
  6. Reescribe con condition una 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.
  7. Encadena dos thunks en un listener con unwrap y try/catch: dispara el segundo solo si el primero tuvo éxito y registra el error si el primero falla.