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

Dónde vive el efecto: thunk, saga o listener

El cierre del nivel es un criterio para decidir dónde alojar un efecto, sin moda ni gusto: leer la forma del flujo y darle la herramienta de su tamaño. La pregunta que ordena todo es una: ¿es un recado con principio y fin, o una conversación entre eventos que se cruzan, se esperan y se cancelan? Antes incluso, hay que descartar el estado de servidor, que casi nunca es un efecto que orquestar sino una caché. La lección construye el eje recado-conversación, reparte RTK Query, thunk, listener y saga en un árbol de decisión con su función explícita, muestra que las cuatro herramientas coexisten y que el criterio se re-evalúa, y cierra conectando con el criterio de la máquina y con el arco entero del track: el efecto es un valor que debe vivir donde el sistema entero pueda verlo.

⏱ 18 min

Tienes tres lugares donde alojar un efecto —thunk, saga, listener— y una capa que a menudo hace innecesarios a los tres: la caché de servidor. Elegir bien no es cuestión de gusto ni de moda, sino de leer la forma del flujo y darle la herramienta de su tamaño. La pregunta que ordena todo lo demás es una sola: ¿este efecto es un recado con principio y fin, o una conversación entre eventos que se cruzan, se esperan y se cancelan? De su respuesta cuelga el resto, y el sesgo correcto —como en cada cierre de este track— es empezar por el escalón más bajo y subir solo cuando una necesidad concreta te obligue, porque pasarse de herramienta se paga cada día en ceremonia y quedarse corto se corrige barato el día que el dolor aparece.

🎯 Al terminar esta lección sabrás
  • Descartar primero el estado de servidor: si es leer y cachear, no es un efecto de middleware.
  • Usar el eje recado-frente-a-conversación para separar el thunk del listener y de la saga.
  • Recorrer el árbol de decisión que reparte RTK Query, thunk, listener y saga.
  • Aplicar el sesgo hacia el escalón más bajo y saber cuándo re-evaluar la elección.

Primero: ¿es un efecto siquiera?

El gasto excesivo más común es orquestar a mano lo que en realidad es caché. Si el flujo es “lee del servidor, muéstralo, mantenlo fresco”, no es un efecto que coreografiar: es una caché, y RTK Query o TanStack Query te regalan la deduplicación de peticiones, la revalidación, la invalidación y los estados de carga y error sin que escribas un solo dispatch. Una fracción enorme de los thunks y las sagas escritos antes de 2021 eran cachés artesanales que reimplementaban, mal, lo que hoy es una línea de configuración. Así que la primera pregunta no es qué middleware, sino si necesitas middleware.

La confusión tiene raíz histórica. En los primeros años de Redux, el estado de servidor y el de cliente se guardaban en el mismo store y se orquestaban con las mismas herramientas, de modo que un GET cacheado y una máquina de checkout compartían mecánica —thunks, a veces sagas— aunque fueran problemas de naturaleza opuesta. La separación entre estado de servidor y estado de cliente, que el track trató en su propio nivel, deshizo el enredo: el primero es caché con revalidación, el segundo es efecto con orquestación. Preguntar “de qué naturaleza es este estado” antes de “qué middleware uso” descarta de un plumazo la mitad de los candidatos a thunk mal colocados.

Reserva thunk, saga y listener para efectos genuinos: comandos que cambian el mundo —enviar un formulario, borrar un registro—, reacciones a tu propio flujo de acciones y orquestaciones de eventos. La lectura de datos remotos cae fuera de este nivel; vive en la capa de estado de servidor que el track trata aparte. Si te descubres escribiendo un efecto cuyo único trabajo es un GET seguido de guardar el resultado, detente: casi seguro estás reinventando una caché que ya existe con mejores garantías.

ℹ️
A veces ni siquiera pertenece a Redux

No todo efecto de cliente vive en el middleware. Un foco imperativo sobre un input, un scroll programático, un play sobre un elemento de vídeo disparado por un clic: esos empiezan y mueren dentro de un componente y a nadie más le importan. Forzarlos a través de una acción solo para ejecutarlos en un listener es ceremonia pura. El middleware se gana su sitio cuando el efecto ha de formar parte del flujo compartido y observable —porque otra lógica reacciona a él, porque debe sobrevivir al componente, porque necesita el store—. Si el efecto nace y muere en un solo componente y nada externo depende de su curso, useEffect es el hogar honesto. La pregunta previa a “qué middleware” es si este efecto es del componente o de la aplicación.

El eje que decide: recado o conversación

Los efectos genuinos tienen dos formas, y distinguirlas resuelve casi toda la elección. Un recado es autocontenido, va de principio a fin y no recuerda a otros eventos: enviar un pago, eliminar un elemento. Esa forma es un thunk —createAsyncThunk—, imperativo, honesto y sin ceremonia. Una conversación es un efecto que ha de reaccionar al flujo, esperar a otras acciones, cancelarse cuando lo superan, agrupar ráfagas o secuenciar pasos: esa forma no cabe en un thunk sin atornillarle banderas y refs que fingen control de flujo.

Entre las conversaciones hay a su vez dos sub-formas. La mayoría necesita el vocabulario —take-latest, debounce, espera-entonces-actúa— pero corre perfectamente en async/await: ese es el listener middleware. Unas pocas exigen la reificación de los generadores —tests sin mocks afirmando sobre las descripciones— o son bucles de vida larga como un websocket o un asistente de muchos pasos: esas, y solo esas, justifican la saga. El eje recado-conversación separa el thunk del resto; el eje reificación-o-vida-larga separa el listener de la saga.

Dos listas cortas afinan el eje. Sube de escalón cuando aparezcan estas señales concretas: necesitas cancelar la ejecución anterior al llegar una nueva, agrupar una ráfaga en una sola corrida, esperar a que ocurra otra acción antes de continuar, mantener vivo un proceso a través de muchos eventos, o reaccionar a acciones que despacha otra parte del sistema. Quédate donde estás cuando el efecto no recuerde a ningún otro evento, no necesite cancelarse, empiece y termine en una sola invocación y nadie más dependa de su curso. Como en el criterio de la máquina del nivel anterior, la asimetría es deliberada: una sola señal fuerte de subida suele bastar para comprar el escalón, mientras que del lado de quedarse basta con que ninguna aparezca, porque el error barato es quedarse corto y el caro es pasarse.

Un ejemplo afila el eje. Piensa en “autoguardar el borrador”. Si es “al pulsar guardar, envía”, es un recado: thunk. Si es “guarda solo, trescientos milisegundos después de la última tecla, cancelando el guardado anterior”, ya es conversación con debounce y take-latest: listener. Y si además ha de sobrevivir a navegaciones, reintentar con espera creciente y coordinarse con un websocket de colaboración en vivo, es coreografía de vida larga: saga. El mismo rótulo de producto —“autoguardar”— cae en tres escalones distintos según su forma real. Por eso el criterio nunca mira el nombre de la función, sino la mecánica del flujo que hay debajo.

🗄️

RTK Query

Cuando el flujo es leer y cachear datos del servidor. No es un efecto que orquestar: es una caché con revalidación e invalidación resueltas.

📮

Thunk

El recado autocontenido: pide, espera, guarda. Un comando con principio y fin, sin memoria de otros eventos. El punto de partida.

🎧

Listener

La reacción al flujo con cancelación, debounce y secuencia, escrita en async/await. La conversación del caso común, sin generadores.

🎬

Saga

La coreografía densa, de vida larga, cancelable y testeable por reificación. La artillería para el diez por ciento más complejo.

Forma del flujo Herramienta idiomática en 2026
Leer y cachear datos del servidor RTK Query o TanStack Query (no es middleware)
Recado con principio y fin (enviar un formulario) createAsyncThunk
Reaccionar al flujo: debounce, take-latest, secuencias Listener middleware
Vida larga, websockets, orquestación test-crítica redux-saga
💡
Un recado con un if sigue siendo un recado

La frontera que más engaña es el condicional inocente. Un thunk que lee getState para saltarse un fetch redundante, o que ramifica según un estado, sigue siendo un recado: consultar el presente para decidir una vez no es “reaccionar al flujo”. Reaccionar al flujo significa ser reinvocado por acciones posteriores que tú no despachaste, esperar eventos, cancelarte al ser superado. Si tu efecto corre una sola vez, de arriba abajo, y solo consulta el estado de pasada, es un thunk por muchos if que tenga. No promuevas a listener porque haya una bifurcación; promuévelo cuando el efecto deba sobrevivir a su propia invocación.

El árbol de decisión

La checklist alimenta un árbol que se recorre de arriba abajo y te deja en el escalón más bajo que sostiene tu flujo, que es siempre el correcto. Cada bifurcación hacia abajo añade poder y coste, y debe estar comprada por una necesidad concreta, nunca por inercia ni por la herramienta que acabas de aprender.

flowchart TD
A[es leer datos del servidor?] -->|si| Q[RTK Query o TanStack Query]
A -->|no| B[reacciona a un flujo de acciones?]
B -->|no, es un recado| T[createAsyncThunk]
B -->|si| C[exige reificacion para test o vida muy larga?]
C -->|no| L[listener middleware]
C -->|si| S[redux-saga]
style Q fill:#a6e3a1,color:#11111b
style T fill:#89dceb,color:#11111b
style L fill:#89b4fa,color:#11111b
style S fill:#cba6f7,color:#11111b

Ese árbol se puede escribir como código, y hacerlo delata que el criterio, una vez tienes las respuestas, no es más que leer la forma del flujo y elegir el escalón mínimo.

type Flujo = {
  esLecturaDeServidor: boolean;
  reaccionaAlFlujo: boolean;
  exigeReificacionOVidaLarga: boolean;
};

function dondeVive(f: Flujo): "rtk-query" | "thunk" | "listener" | "saga" {
  if (f.esLecturaDeServidor) return "rtk-query";
  if (!f.reaccionaAlFlujo) return "thunk"; // recado autocontenido
  if (f.exigeReificacionOVidaLarga) return "saga"; // conversacion densa
  return "listener"; // conversacion del caso comun
}

La función es honesta hasta cierto punto y deshonesta a partir de ahí. Honesta porque muestra que la decisión es mecánica en cuanto tienes las respuestas; deshonesta porque cada booleano esconde un juicio —¿esto reacciona al flujo o es un recado con un if?— que ningún tipo puede tomar por ti. El criterio como código es una caricatura útil: dibuja la forma de la decisión, pero las respuestas siguen exigiendo que leas tu problema con honestidad y sin el sesgo de la herramienta recién estrenada.

Este árbol es local: reparte los efectos de un flujo entre cuatro herramientas de una misma familia, la de Redux. Al final del track lo reencontrarás absorbido en un árbol mayor —el de elegir arquitectura entera—, donde la pregunta ya no será thunk contra saga, sino Redux frente a signals, frente a máquinas de estado, frente a las arquitecturas unidireccionales como Elm o TCA, cada una con su propia respuesta a dónde viven los efectos. El criterio que acabas de aprender es una rama de aquel; dominarlo aquí, en pequeño y dentro de una sola familia, es lo que hará legible el mapa completo cuando llegues a él.

Coexisten; el criterio se re-evalúa

Las cuatro herramientas no son religiones rivales, son una caja de herramientas, y una aplicación real de 2026 las mezcla sin pudor: RTK Query para los datos de servidor, un puñado de createAsyncThunk para los comandos, varios listeners para el pegamento reactivo y quizá una única saga en el rincón donde vive un websocket. Elegir una no es jurar lealtad; es dar a cada flujo su tamaño. El error no es usar varias, es usar la equivocada para una forma que pedía otra.

Y la elección no es permanente. Un thunk que ha ido criando banderas y refs para fingir take-latest te está diciendo que quiere ser un listener; una saga cuya coreografía se evaporó cuando la función se simplificó quiere volver a ser un thunk. Re-evalúa cuando el flujo cambie de forma, promoviendo o degradando un escalón sin drama, exactamente como el criterio de la máquina del nivel anterior pedía re-evaluar cuando el problema cambiaba. El coste de quedarse un escalón corto es tan real como el de pasarse, y ambos se corrigen moviéndose, no firmando la decisión una vez.

Un mapa concreto ayuda a ver la convivencia. Imagina una app de mensajería de 2026: los mensajes y los contactos se leen con RTK Query, que cachea y revalida; enviar un mensaje es un createAsyncThunk, un recado con su actualización optimista y su rollback; marcar como leído al hacer scroll es un listener con debounce que agrupa la ráfaga; sincronizar el tema con el del sistema operativo es otro listener con predicate; y el único hilo de websocket que empuja los mensajes entrantes vive en una saga, con su bucle de take y su cancelación al cerrar sesión. Cinco flujos, cuatro herramientas, ningún conflicto: cada uno recibió el tamaño de su forma. Eso, y no la lealtad a una librería, es el aspecto de una capa de efectos madura.

📝
Mezclar tiene un coste: la coherencia del equipo

El criterio es por flujo, pero la caja de herramientas es por equipo, y ahí aparece una tensión honesta. Cuatro idiomas distintos son cuatro cosas que quien llega nuevo debe aprender antes de leer el código con soltura. Una app pequeña suele estar mejor servida estandarizando en dos —RTK Query más thunk— y subiendo a listener o saga solo cuando un flujo concreto lo exija, que repartiéndose desde el día uno entre las cuatro por pureza taxonómica. Mantén la caja tan pequeña como los flujos permitan y documenta qué herramienta posee qué clase de efecto; la coherencia legible de la base de código también es un coste que el criterio debe pesar, no solo el ajuste perfecto de cada flujo a su escalón.

⚠️
El futuro imaginado no compra escalones

La trampa más común es subir de escalón por un “podría necesitarlo”: elegir saga hoy porque el flujo quizá se vuelva complejo mañana. Pero la mayoría de los flujos no crecen en complejidad de orquestación; crecen en detalles dentro de una forma que ya tenían. Solo compra un escalón el crecimiento que suma reacción al flujo, cancelación o vida larga, y solo si tienes evidencia concreta de que llegará. Traer la artillería “por si acaso” es pagar hoy, con certeza, la ceremonia y la curva de un seguro contra un riesgo que quizá nunca ocurra.

Dónde vive el efecto es la última pregunta de propiedad del track

Este criterio cierra un arco que empezó en el nivel 1. Allí la pregunta era de quién es cada dato; aquí es de quién es cada cambio-con-consecuencias, y la respuesta tiene la misma forma: un único hogar, explícito y observable, en lugar de copias dispersas por los componentes. Las cuatro herramientas no compiten, son una escalera de poder y coste crecientes, y toda la destreza consiste en negarse a subir más alto de lo que la forma del flujo exige. Por debajo de sus API distintas coinciden en lo único que de verdad importa —que el efecto pertenece a la costura del middleware, indexado por el flujo de acciones, fuera del núcleo puro y fuera de los componentes dispersos— y discrepan solo en ergonomía; por eso el ingeniero maduro no tiene favorita. Lee primero la forma —recado o conversación, un disparo o suscripción, lectura cacheable o efecto genuino, crítica en tests o no, corta o de vida larga— y solo entonces, con la forma ya dibujada, elige el escalón más bajo que la sostiene con honestidad. Elegir la herramienta que acabas de aprender, o la que el equipo estandarizó, o la más potente “para ir sobre seguro”, son tres caras del mismo defecto: mirar la herramienta antes que el problema. Invierte el orden —problema primero, herramienta después— y los cuatro nombres dejan de ser frameworks enfrentados y se revelan como lo que siempre fueron: cuatro tamaños de una misma idea, la de que el efecto es un valor y, como el estado y como el cambio, debe vivir donde el sistema entero pueda verlo.

⚔️ Reparte tus efectos por el árbol
  1. Toma tres efectos de tu app —uno que sea un recado, uno reactivo y una lectura de servidor— y recórrelos por el árbol antes de mirar cómo están implementados hoy.
  2. Busca un thunk que haya criado banderas o refs para fingir take-latest o debounce; reescríbelo como listener y cuenta las líneas que borras.
  3. Encuentra un thunk que sea una caché a mano —GET y guardar— y elimínalo en favor de RTK Query o TanStack Query.
  4. Para cada saga que tengas, cuenta cuántos de sus efectos necesitan de verdad reificación o vida larga; si son pocos, quiere ser un listener: arguméntalo antes de degradarla.
  5. Convierte cada “podríamos necesitar saga más adelante” en una necesidad concreta y con fecha; descarta las que no puedas fechar, porque eran la ley del instrumento, no previsión.
  6. Dibuja el mapa de efectos de un módulo real: lista cada efecto y anota en qué escalón cae y por qué; marca como candidatas a mover las divergencias con la implementación actual.
  7. Toma un efecto que hoy vive en un useEffect de un componente y decide, con el eje de propiedad, si pertenece al componente o al flujo compartido; muévelo solo si algo externo depende de él.