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

redux-saga: efectos como valores con generadores

redux-saga toma el camino opuesto al thunk: en vez de ejecutar el efecto, lo describe. Una saga es un generador que en cada yield entrega un objeto plano que dice qué hacer —call, put, take, race— y el middleware lo ejecuta y reanuda el generador con el resultado. Esa inversión —efectos como datos, no como acciones— compra tests sin mocks y cancelación limpia, porque pausar un generador es trivial. Esta lección explica el giro de efectos-como-valor y el patrón interpreter que hay detrás, monta el patrón watcher/worker con takeLatest, y orquesta flujos complejos con race, take, fork y cancel. Cierra con el criterio: saga es la artillería para la conversación entre eventos, no para el recado.

⏱ 18 min

redux-saga toma el camino opuesto al del thunk: en lugar de ejecutar el efecto, lo describe. Una saga es un generador —una función que puede pausarse y reanudarse— que en cada yield no lanza la petición ni hace el dispatch, sino que entrega un objeto plano que dice qué habría que hacer: call(api, id), put(accion), take(evento). El middleware de saga recibe esa descripción, ejecuta el efecto de verdad y reanuda el generador inyectándole el resultado en el punto exacto donde se pausó. Esa inversión —efectos como valores, no como acciones— compra dos cosas enormes: unos tests que se escriben afirmando sobre objetos planos sin tocar el mundo, y una cancelación limpia, porque interrumpir un generador es trivial. El precio es una curva real: dejas de pensar en recados y empiezas a pensar en coreografía.

🎯 Al terminar esta lección sabrás
  • Entender el giro central: yield describe el efecto como un valor y el middleware lo ejecuta.
  • Reconocer por qué los efectos como datos hacen los tests triviales y la cancelación limpia.
  • Montar el patrón watcher/worker con takeLatest, call y put.
  • Orquestar flujos complejos y cancelables con race, take, fork y cancel.

Efectos como valores: yield describe, no ejecuta

Un generador es una función marcada con asterisco que puede rendir el control a mitad de camino: cada yield entrega un valor hacia fuera y suspende la ejecución, y quien la reanuda puede inyectar un valor de vuelta en ese mismo punto. Saga explota ese canal de doble sentido sin piedad: la saga rinde una descripción de efecto hacia el middleware, el middleware ejecuta el efecto y reanuda la saga con el resultado. Por eso const datos = yield call(api.getUsuario, id) se lee como código bloqueante —“trae los datos y sigue”— cuando en realidad es una pausa cooperativa: la saga se congela ahí hasta que el middleware le devuelve la respuesta.

La clave, y lo que la separa por completo del thunk, es que call(fn, ...args) no llama a fn: devuelve un objeto plano que significa “llama a fn con estos argumentos”. La saga jamás toca la red ni el store; solo fabrica descripciones. Es el patrón interpreter de la programación funcional —el mismo espíritu de la free monad—: se separa el qué —una estructura de datos que declara la intención— del cómo —un intérprete que la lleva a cabo—. La saga es casi pura: toda la impureza vive en el middleware que interpreta sus yield. Este reparto es el corazón conceptual de la librería, y todo lo demás son variantes de efecto.

Ese desacople entre describir y ejecutar es también la razón de que saga inspirara al listener middleware de la próxima lección. Una vez ves los efectos como valores, codicias su vocabulario —take, race, fork, la cancelación cooperativa— aunque no siempre quieras pagar los generadores ni la indirección de la reificación. El listener nace precisamente de esa tensión: conservar la potencia de coordinación de saga soltando su maquinaria más cara. Entender saga a fondo, por tanto, no es solo aprender una librería más, sino ver de dónde viene el diseño de su relevo.

💡
Se testea afirmando sobre objetos planos, sin mocks

Como la saga rinde objetos planos, la pruebas iterando el generador a mano: gen.next().value debe ser igual a call(api.getUsuario, '7') —una comparación profunda de un objeto, sin red ni mock de fetch—. Luego le inyectas un resultado falso con gen.next(datosFalsos) y afirmas que el siguiente valor rendido es el put esperado. El test es un guión de yield previstos. Compáralo con testear un thunk, donde tienes que espiar fetch, controlar promesas y esperar microtareas: aquí no ejecutas nada, solo compruebas que la saga pide lo correcto en el orden correcto.

El patrón watcher/worker

La unidad de organización de saga es la pareja vigía-obrero. Un watcher escucha tipos de acción y, cuando llega uno, engendra una saga worker que hace el trabajo. Con takeEvery arrancas un obrero por cada acción, concurrentes; con takeLatest cancelas al obrero en vuelo en cuanto llega otra acción del mismo tipo. Ese takeLatest es, en una palabra, el “quédate solo con la última” que en la lección de thunk costaba banderas, refs y contadores: aquí la semántica de cancelación es vocabulario, no contabilidad manual.

import { call, put, takeLatest } from "redux-saga/effects";
import { api } from "./api";

function* cargarUsuario(accion: { type: string; payload: string }) {
  try {
    const datos: { nombre: string } = yield call(api.getUsuario, accion.payload);
    yield put({ type: "usuario/exito", payload: datos }); // put: describe un dispatch
  } catch (e) {
    yield put({ type: "usuario/error", payload: String(e) });
  }
}

export function* watcherUsuario() {
  // takeLatest cancela el worker anterior si llega otra peticion
  yield takeLatest("usuario/pedir", cargarUsuario);
}
flowchart TD
W[watcher escucha usuario/pedir] -->|llega accion| A[arranca worker A]
W -->|llega otra accion| C[cancela worker A]
C --> B[arranca worker B]
A --> P[put exito o error]
B --> P
style C fill:#f38ba8,color:#11111b
style P fill:#a6e3a1,color:#11111b

Orquestar lo complejo: race, take, cancel

Donde saga se separa de todo lo anterior es en los efectos de control de flujo. race corre varios efectos a la vez y se queda con el primero que termine, cancelando al resto: un timeout es literalmente una carrera entre la respuesta y un delay. take pausa la saga hasta que llegue una acción concreta, lo que permite escribir secuencias y handshakes como código en línea recta —“abre el diálogo, ahora espera a que confirmen, luego actúa”—. fork lanza una saga hija no bloqueante, cancel la interrumpe y un finally con cancelled() deja que el obrero limpie tras de sí cuando lo cancelan. Procesos de vida larga —un bucle de websocket, un asistente de varios pasos— se vuelven procedimientos legibles en lugar de una maraña de callbacks.

import { call, put, race, delay, take } from "redux-saga/effects";
import { api } from "./api";

function* cargarConTimeout(id: string) {
  // race: corre ambos y gana el primero; el perdedor se cancela solo
  const { datos, timeout } = yield race({
    datos: call(api.getUsuario, id),
    timeout: delay(5000),
  });
  yield put(timeout ? { type: "usuario/timeout" } : { type: "usuario/exito", payload: datos });
}

function* flujoConfirmacion() {
  yield put({ type: "dialogo/abrir" });
  // take: pausa hasta que llegue exactamente una de estas acciones
  const respuesta: { type: string } = yield take(["dialogo/aceptar", "dialogo/cancelar"]);
  if (respuesta.type === "dialogo/aceptar") yield call(api.confirmar);
}

Conviene distinguir dos familias de efecto por su bloqueo. call y take son bloqueantes: la saga se detiene hasta que se resuelven. fork es no bloqueante: lanza una saga hija y sigue de inmediato, devolviendo una tarea que más tarde puedes cancel. Sobre fork se levanta la concurrencia estructurada de saga, y en particular la root saga: el único punto de arranque que el middleware ejecuta, y que bifurca todos tus watchers a la vez con all, de modo que escuchan en paralelo sin bloquearse entre sí.

import { all, fork } from "redux-saga/effects";
import { watcherUsuario } from "./usuario";
import { watcherBusqueda } from "./busqueda";

// La root saga: un unico arranque que bifurca todos los watchers en paralelo.
export function* rootSaga() {
  yield all([fork(watcherUsuario), fork(watcherBusqueda)]);
}
📝
all espera a todos; race, al primero

all y race son las dos formas de combinar efectos concurrentes, y son duales. all([a, b]) es como Promise.all: se resuelve cuando todos terminan y falla si alguno falla, y sirve para el abanico —arrancar varios watchers, pedir varios recursos a la vez—. race({ a, b }) se queda con el primero que acabe y cancela al resto, y sirve para el o-lo-uno-o-lo-otro —respuesta contra timeout, datos contra cancelación—. Entre las dos cubren casi toda la concurrencia que necesitarás, y ambas heredan gratis la cancelación: en race, el perdedor se cancela; en all, si uno falla, los hermanos se cancelan.

📞

call

Describe una llamada bloqueante a una función o promesa. La saga se pausa hasta que el middleware la resuelve y devuelve el valor.

📤

put

Describe un dispatch de una acción hacia el store, sin ejecutarlo la saga: lo hace el intérprete.

🎧

take

Pausa la saga hasta que llegue cierta acción; convierte el flujo de eventos en una secuencia que se lee de arriba abajo.

🏁

race

Corre varios efectos y se queda con el primero que acabe; los demás se cancelan. Timeouts y cancelaciones nacen de aquí.

⚠️
Poder y precio suben juntos: no es el enfoque por defecto

Los generadores son ajenos a mucha gente, y el modelo “parece bloqueante pero es cooperativo” hace tropezar hasta a quien ya sabe async. Hay más ceremonia que en un thunk y una curva que se paga entera antes del primer beneficio. Por eso en 2026 saga no es el punto de partida: es la artillería pesada para orquestación genuinamente dirigida por eventos —websockets, asistentes complejos, sagas que reaccionan a secuencias de acciones, cancelación fina y omnipresente—. Para el caso corriente de “cuando llegue esta acción, haz este efecto”, el listener middleware de la próxima lección es más ligero y basta. Traer saga a un problema que era un recado es pagar una coreografía que nadie te pidió.

Saga es Redux descubriendo que también el efecto es un valor

La idea honda de saga es que, una vez los efectos son valores, un programa asíncrono se vuelve una estructura de datos que puedes inspeccionar, testear, repetir y cancelar, exactamente igual que la acción era un valor que podías registrar y viajar en el tiempo. El thunk hizo que dispatch aceptara una computación; saga va más lejos y convierte el efecto mismo en computación-como-dato, cerrando el círculo que este track lleva dibujando desde el principio: el estado es un valor en el nivel 1, el cambio es un valor —la acción—, la derivación es un valor —los selectores— y ahora también el efecto es un valor. Todo lo que el programa hace queda cosificado, y esa cosificación es justo la razón de sus dos superpoderes: las sagas se testean sin mocks porque afirmas sobre las descripciones, y se cancelan sin banderas porque no interrumpes una función corriendo, sino que simplemente dejas de interpretar una descripción. El coste inseparable es que has de pensar como coreógrafo —en take, en race, en fork, en un lenguaje de cuándo-y-si-acaso en vez de haz-esto-y-luego-aquello— y ese lenguaje es de verdad más difícil. Saga se gana el sueldo exactamente cuando el efecto es una conversación larga entre eventos que se cruzan, se esperan y se cancelan, y te cobra de más cuando solo era un recado con principio y fin. La madurez no es dominar su API, sino ver que su poder y su precio suben juntos, y negarte a pagar una coreografía que tu problema no tiene.

⚔️ Coreografía en generadores
  1. Escribe un worker con call y put y un watcher con takeEvery; luego cámbialo a takeLatest y dispara la acción dos veces seguidas para ver cancelarse al obrero anterior.
  2. Testea el worker iterando el generador: afirma el call rendido, inyéctale un resultado falso con gen.next(...), afirma el put siguiente. Sin espiar fetch.
  3. Añade un timeout de cinco segundos con race entre la carga y un delay, y fuerza la rama de timeout apuntando a un endpoint lento.
  4. Modela un diálogo de confirmación con put(abrir) seguido de take(['aceptar', 'cancelar']) y ramifica; observa cómo la secuencia se lee de arriba abajo.
  5. Añade un finally con cancelled() a un worker y registra cuándo lo cancela takeLatest; confirma que la limpieza corre aunque el obrero muera a mitad.
  6. Escribe la rootSaga con all y fork y arranca dos watchers; comprueba que ambos escuchan a la vez sin bloquearse mutuamente.
  7. Convierte un call bloqueante en un fork no bloqueante, observa cómo la saga continúa sin esperar, y luego cancela la tarea bifurcada con cancel.