Generadores y efectos declarativos: la saga describe, no ejecuta
El nivel abre por el cimiento conceptual de redux-saga: un generador no es azúcar para async, es una corrutina con un canal de doble sentido donde el que rinde el control no decide quién lo reanuda ni con qué valor. Sobre esa inversión se levanta la idea central de la librería: call, put y take no hacen nada, devuelven objetos planos que significan una intención, y toda la impureza queda confinada en un intérprete —el middleware— que lee esas descripciones y las lleva a cabo. Esta lección desmonta el generador como protocolo, muestra la forma literal de un efecto reificado, ubica el patrón en la tradición del interpreter y las free monads, y nombra el precio real: una indirección extra, trazas peores y un yield que TypeScript no puede tipar sin ayuda.
La frase que resume redux-saga cabe en seis palabras: una saga no ejecuta, describe. Cuando escribes yield call(api.getUsuario, id) no estás llamando a api.getUsuario; estás fabricando un objeto plano que significa la llamada y entregándoselo a otro para que decida si la hace, cuándo y con qué. Esa distinción entre denotar un efecto y realizarlo parece una sutileza de purista, pero es exactamente de donde salen los dos superpoderes de la librería —tests sin mocks y cancelación sin banderas— y también su curva. Para verla hay que empezar por debajo, en el mecanismo que la hace posible: el generador, que no es azúcar sintáctico para promesas sino una corrutina cuyo punto entero es que quien se suspende ya no controla quién lo despierta.
- Entender el generador como corrutina con canal de doble sentido, no como una variante de
asyncyawait. - Ver la forma literal de un efecto reificado:
callno llama, devuelve un objeto plano con una etiqueta y una carga. - Ubicar el diseño en la tradición del patrón interpreter y de los efectos algebraicos: separar el qué del cómo.
- Nombrar el precio honesto de la reificación: indirección, trazas de pila peores y un
yieldque TypeScript no tipa solo.
El generador es una corrutina, no una promesa disfrazada
Una función marcada con function* no se ejecuta al llamarla: devuelve un iterador. Nada de su cuerpo corre hasta que alguien invoca gen.next(), y entonces avanza hasta el primer yield, entrega el valor rendido en la propiedad value de un objeto con value y done, y se congela. El estado local —variables, posición del bucle, el marco de pila entero— queda intacto en memoria, esperando. Esa capacidad de suspender y reanudar una computación conservando su contexto es lo que en teoría de lenguajes se llama corrutina, y es una primitiva estrictamente más general que la de las promesas.
Lo decisivo es que el canal va en los dos sentidos. gen.next(valor) no solo reanuda: inyecta valor como el resultado de la expresión yield que estaba suspendida. Por eso const datos = yield call(...) puede leerse como si fuese código bloqueante que devuelve datos, cuando en realidad la saga no obtuvo nada por sí misma: alguien de fuera le puso ese dato en la mano. Compáralo con await, que también suspende, pero cuyo reanudador está fijado por el lenguaje —siempre el planificador de microtareas, siempre con el valor resuelto de la promesa—. Con yield no hay reanudador privilegiado: el generador declara que se detiene, y quién lo despierta y con qué es una decisión externa.
Ahí está la inversión de control que lo cambia todo. Una función async sabe demasiado: sabe que su efecto va a ocurrir de verdad, que ocurrirá ahora y que el resultado vendrá de la promesa que ella misma creó. Un generador no sabe nada de eso, y esa ignorancia es una virtud de diseño, porque deja libre la pregunta de qué significa realmente el valor rendido. El consumidor puede ejecutarlo, registrarlo, sustituirlo por un valor falso, ignorarlo o cancelar el generador sin reanudarlo jamás. Todo el resto de este nivel es consecuencia de ese grado de libertad.
// Un interprete de juguete: el generador rinde numeros, el consumidor decide que significan.
function* contar() {
const a: number = yield 1; // se suspende; el valor lo pone quien reanuda
const b: number = yield a + 1;
return a + b;
}
const gen = contar();
gen.next(); // value: 1, done: false -> arranca y se para en el primer yield
gen.next(10); // value: 11, done: false -> inyecta 10 como resultado de aquel yield
gen.next(100); // value: 110, done: true -> la saga nunca supo de donde salieron
El ejemplo anterior no toca una sola promesa y sin embargo ya contiene la idea completa. La asincronía no está en el generador: está en el consumidor, que puede tardar lo que quiera entre recibir el valor rendido y llamar a next. Un generador es un mecanismo de suspensión síncrono y determinista; convertirlo en un motor asíncrono es trabajo del bucle que lo conduce. Interiorizar esto evita el error mental más común del principiante en sagas, que es leer yield como un await con otro nombre y deducir de ahí conclusiones falsas sobre cuándo corre cada cosa.
Un efecto es un objeto plano con una etiqueta
Si el generador da la libertad, los efectos la usan. call(fn, ...args) no invoca fn: construye y devuelve un objeto plano con una etiqueta que identifica la clase de efecto y una carga con la función y sus argumentos. put(accion) no despacha: devuelve un objeto que dice habría que despachar esta acción. select(selector) no lee el store: devuelve un objeto que dice hazme llegar esta derivación del estado. Todos son, literalmente, datos: serializables, comparables por igualdad profunda, inspeccionables en un console.log. La palabra técnica para ese movimiento es reificación —convertir en objeto de primera clase algo que antes era una operación—, y es la misma jugada que hizo Redux al convertir el cambio de estado en una acción.
import { call, put } from "redux-saga/effects";
import { api } from "./api";
const efecto = call(api.getUsuario, "7");
// efecto es aproximadamente:
// { '@@redux-saga/IO': true, combinator: false, type: 'CALL',
// payload: { context: null, fn: api.getUsuario, args: ['7'] } }
// Comparar dos descripciones es comparar dos objetos, sin red y sin mocks.
const mismo = JSON.stringify(Object.keys(call(api.getUsuario, "7").payload));
const despacho = put({ type: "usuario/exito", payload: { id: "7" } });
Esta es la diferencia irreductible con el thunk. Un thunk recibe dispatch y lo usa: su efecto es un acto, y para observarlo hay que interceptar el mundo —espiar fetch, sustituir el reloj, controlar el orden de las microtareas—. Una saga no recibe capacidad de actuar sobre nada; solo fabrica descripciones y las entrega hacia arriba. La consecuencia es que el cuerpo de una saga es, en el sentido estricto, una función casi pura: dada la misma secuencia de valores inyectados, produce siempre la misma secuencia de descripciones. Toda la impureza del sistema se ha desplazado a un único lugar.
Vale la pena notar que la reificación es parcial y deliberadamente barata. Un sistema de efectos completo exigiría que la propia función invocada fuese también una descripción, y en cambio call guarda una referencia directa a api.getUsuario: el efecto describe qué se invoca solo hasta el nivel de la identidad de la función, no de su contenido. Esa media medida es lo que permite que la librería sea usable en JavaScript corriente sin un lenguaje de instrucciones propio, y también lo que explica un límite que reaparecerá en el capítulo de tests: comparar dos efectos compara referencias y argumentos, nunca comportamientos.
El intérprete: toda la impureza en un solo sitio
Ese único lugar es el middleware. Su bucle es conceptualmente trivial: pide el siguiente valor al generador, mira la etiqueta del efecto rendido, ejecuta lo que esa etiqueta significa —llamar a la función, despachar la acción, leer el estado, esperar a que llegue una acción— y reanuda el generador inyectándole el resultado, o le lanza dentro un error con gen.throw si el efecto falló. Repite hasta que done sea verdadero. La saga es la gramática; el middleware es la semántica.
flowchart LR S[saga rinde call api id] -->|descripcion| M[middleware interprete] M -->|ejecuta de verdad| W[mundo red store reloj] W -->|resultado o error| M M -->|next resultado o throw error| S S -->|yield put exito| M style S fill:#a6e3a1,color:#11111b style M fill:#cba6f7,color:#11111b style W fill:#f38ba8,color:#11111b
Escrito como código, ese bucle cabe en una pantalla y no contiene ni una línea de lógica de negocio. Verlo entero desmitifica la librería: lo que parecía un motor sofisticado es un despachador sobre una etiqueta, y todo lo que redux-saga añade encima —el árbol de tareas, las políticas de escucha, la cancelación— son casos más de ese mismo despacho.
type Efecto = { type: "CALL"; fn: Function; args: unknown[] } | { type: "PUT"; accion: unknown };
async function interpretar(gen: Generator, dispatch: (a: unknown) => void) {
let entrada: unknown;
let error: unknown = null;
while (true) {
// gen.throw reinyecta el fallo del mundo dentro del try de la saga
const paso = error ? gen.throw(error) : gen.next(entrada);
error = null;
if (paso.done) return paso.value;
const efecto = paso.value as Efecto;
try {
if (efecto.type === "CALL") entrada = await efecto.fn(...efecto.args);
else if (efecto.type === "PUT") entrada = dispatch(efecto.accion);
} catch (e) {
error = e; // se lo devolvemos a la saga en la proxima vuelta
}
}
}
Esta arquitectura tiene nombre y linaje. Es el patrón interpreter clásico, y en programación funcional es el esqueleto de las free monads y de los sistemas de efectos algebraicos: se construye un árbol de instrucciones como estructura de datos inerte y luego se le aplican intérpretes distintos —uno de producción, uno de test, uno que registra, uno que reproduce— sin tocar el programa. redux-saga es una instancia pragmática y monomórfica de esa idea: un solo intérprete de serie, pero con la propiedad esencial intacta, que es que el programa y su ejecución son cosas separables. De ahí salen los capítulos siguientes de este nivel: la cancelación es dejar de interpretar, y el test es interpretar con otras reglas.
Conviene además fijar en qué sentido exacto una saga es pura, porque la afirmación suele hacerse a la ligera. No lo es en el sentido matemático —puede leer variables de módulo, construir fechas o generar identificadores si la dejas—, sino en un sentido operativo y suficiente: si la disciplina se respeta y todo contacto con el exterior pasa por un efecto rendido, entonces el cuerpo de la saga es una función determinista de la secuencia de valores que le inyectan. Esa propiedad es la que hace reproducible un flujo: guardando la lista de descripciones rendidas y de valores inyectados tienes una grabación completa de lo que ocurrió, y volver a alimentar la saga con ella la lleva por el mismo camino sin tocar el mundo. La impureza no se ha eliminado, se ha empujado a una frontera donde es observable.
Nada en el sistema de tipos impide que una saga llame a fetch directamente, lea Date.now o toque el localStorage sin pasar por un efecto. Si lo haces, no explota nada: la saga sigue funcionando y solo pierde, en silencio, las tres propiedades por las que la elegiste, porque esa parte ya no es cancelable, ni testeable sin dobles, ni reproducible. Por eso la regla práctica en un equipo es tan simple como innegociable: si la línea toca el mundo, va detrás de un yield. Es la misma clase de acuerdo que sostiene la pureza de los reducers, y se degrada igual de rápido cuando se acepta una excepción.
Generador
Corrutina que se suspende conservando su contexto y cuyo reanudador es externo. El canal de next va en ambos sentidos.
Efecto
Objeto plano con etiqueta y carga. call, put, take y select no actúan: significan. Son datos comparables.
Intérprete
El middleware: única frontera impura. Lee la etiqueta, ejecuta el mundo y reanuda la saga con el resultado o el error.
Separabilidad
Si programa y ejecución son cosas distintas, puedes cambiar la segunda sin tocar el primero: testear, registrar, cancelar.
La reificación no es gratis. La indirección hace que el salto de la saga al efecto real no aparezca en la traza de pila, así que depurar exige entender el bucle del intérprete y no solo tu código. Y hay un fallo estructural en el tipado: la expresión yield no puede tipar el valor que le inyectan, porque TypeScript no sabe qué le devolverá el intérprete, así que escribir const datos: Usuario = yield call(api.getUsuario, id) es una anotación que tú prometes y nadie verifica. La solución idiomática es typed-redux-saga, que usa yield* y delegación de generadores para recuperar la inferencia real. Antes de adoptar sagas conviene saber que su promesa de rigor se apoya, en el tipado plano, en una mentira que mantienes a mano.
Lo que redux-saga descubre —y lo que sobrevive a la librería, que puede envejecer— es que un programa asíncrono admite ser tratado como una estructura de datos. No una metáfora: una estructura literal, hecha de objetos planos que puedes imprimir, comparar, guardar y reproducir. Redux ya había reificado el cambio de estado en la acción, y con ello ganó el registro, el viaje en el tiempo y el reducer testeable por igualdad de valores; saga aplica el mismo gesto un nivel más arriba y reifica el efecto, de modo que el hacer también se vuelve un valor. La consecuencia es que la pregunta qué debe ocurrir queda escrita en un lenguaje sin poder de causar nada, y la pregunta cómo ocurre queda encerrada en un intérprete que no contiene lógica de negocio. Esa frontera es la que compra todo lo demás y explica por qué los tres capítulos que siguen no son características añadidas sino corolarios: si el efecto es una descripción, cancelar es simplemente no interpretarla, y testear es interpretarla con reglas distintas, sin red ni reloj ni mocks. También explica el precio con la misma honestidad: al insertar una capa entre la intención y el mundo pagas indirección, trazas más opacas y un sistema de tipos que no llega solo hasta el otro lado del yield. La lección duradera no es aprender un vocabulario de efectos, sino reconocer la maniobra y saber cuándo vale la pena: reificar convierte comportamiento en datos, y los datos se inspeccionan, se comparan y se controlan. Cuando tu problema no necesita esa inspección, la maniobra es puro coste; cuando la necesita, ninguna cantidad de disciplina manual con promesas la sustituye.
- Escribe un generador que rinda tres números y condúcelo a mano con
next, inyectando valores distintos en cada reanudación. Anota qué recibe cadayield. - Importa
callderedux-saga/effects, llámalo con una función que imprima por consola y comprueba que no imprime nada: solo se creó un objeto. - Imprime ese objeto y describe sus campos. Identifica cuál es la etiqueta y cuál la carga.
- Escribe un intérprete de veinte líneas: recorre el generador y, si el efecto tiene etiqueta de llamada, ejecuta la función con sus argumentos y reanuda con el resultado.
- Añade a tu intérprete el manejo de errores con
gen.throwy comprueba que untryycatchdentro de la saga captura el fallo del mundo exterior. - Escribe un segundo intérprete que, en vez de ejecutar, devuelva siempre un valor fijo. Observa que la misma saga produce ahora un test determinista.
- Anota un
yield callcon un tipo mentiroso a propósito y comprueba que TypeScript no protesta. Repite contyped-redux-sagay compara.