wandres.dev
REDUX-SAGA A FONDO · efectos con generadores

Concurrencia: fork, spawn, políticas de take, race y all

Una saga sola es una historia lineal; un sistema real es un bosque de historias que corren a la vez, se esperan, se pisan y a veces deben pisarse a propósito. Esta lección instala el modelo de concurrencia estructurada de redux-saga: fork crea una tarea hija atada al padre, con errores que suben y cancelaciones que bajan, mientras spawn la desata y la convierte en raíz independiente. Sobre esa base se explican las políticas de escucha —takeEvery, takeLatest, takeLeading, throttle y debounce— no como atajos intercambiables sino como respuestas distintas a la pregunta de qué hacer cuando llega un evento mientras el anterior sigue vivo, con la condición de carrera de la respuesta rancia como caso de prueba. Cierra con el par dual all y race, abanico y carrera, de donde salen casi todos los timeouts del mundo.

⏱ 20 min

Hasta aquí la saga era una historia que se leía de arriba abajo. Un sistema real, en cambio, es un bosque: varios procesos vivos a la vez, unos hijos de otros, algunos efímeros y otros de vida larga, que compiten por el mismo store y por los mismos recursos. redux-saga no resuelve eso con hilos ni con promesas sueltas, sino con un modelo de concurrencia estructurada donde las tareas forman un árbol explícito y las relaciones padre e hijo tienen semántica: los errores suben, las cancelaciones bajan y un padre no se da por terminado mientras le quede un hijo atado. Entender ese árbol —y saber cuándo cortar una rama con spawn— es lo que separa orquestar de improvisar, porque casi todos los bugs de concurrencia en sagas son en realidad malentendidos sobre quién es hijo de quién.

🎯 Al terminar esta lección sabrás
  • Distinguir fork de spawn por su relación con el árbol de tareas: atada frente a desatada.
  • Entender la propagación en el árbol: errores hacia arriba, cancelaciones hacia abajo, terminación que espera a los hijos.
  • Elegir política de escucha con criterio entre takeEvery, takeLatest, takeLeading, throttle y debounce.
  • Usar all y race como el par dual de abanico y carrera, y derivar de ahí timeouts y cancelaciones.

fork y spawn: el árbol de tareas

call bloquea; fork no. yield fork(saga, arg) lanza una tarea hija y devuelve de inmediato un descriptor de tarea, de modo que el padre sigue su curso mientras el hijo vive por su cuenta. Ese descriptor no es decorativo: expone cancel, isRunning y result, y con join puedes esperar más tarde a una tarea que lanzaste antes, que es justo la forma de disparar varias cosas en paralelo y recogerlas cuando convenga.

Lo esencial es que fork crea una tarea atada, y esa atadura define tres reglas que hay que memorizar. Primera: si el hijo lanza un error no capturado, el error sube al padre, que si no lo trata muere y se lleva por delante a sus otros hijos. Segunda: si cancelan al padre, la cancelación baja a todos sus hijos atados, recursivamente. Tercera: el padre no termina de verdad hasta que todos sus hijos atados han terminado, aunque su cuerpo haya llegado al final; una rootSaga que bifurca watchers permanentes nunca completa, y eso es correcto.

spawn rompe la atadura. Crea una tarea que es raíz de su propio árbol: sus errores no suben, su vida no depende del padre y cancelar al padre no la toca. Es la herramienta del aislamiento de fallos, y su caso canónico es el mismo que el de los supervisores en el modelo de actores: si una rama secundaria puede fallar sin que el sistema deba caer —telemetría, sincronización opcional, un socket auxiliar— se desata, y si además debe reponerse sola, se envuelve en un bucle que la vuelve a lanzar cuando muera.

import { fork, spawn, call, join, delay } from "redux-saga/effects";

// Bifurcacion atada: si esta cae, arrastra al padre.
function* principal() {
  const tarea = yield fork(sincronizarCarrito);
  yield call(hacerOtraCosa);
  yield join(tarea); // recoge el resultado cuando convenga
}

// Bifurcacion desatada y auto reparable: aislamiento de fallos.
function* supervisar(saga: () => Generator) {
  yield spawn(function* () {
    while (true) {
      try {
        yield call(saga);
        break;
      } catch (e) {
        yield delay(1000); // reintenta sin tumbar a nadie
      }
    }
  });
}
flowchart TD
R[rootSaga] -->|fork| W1[watcher usuario]
R -->|fork| W2[watcher busqueda]
R -->|spawn| T[telemetria desatada]
W2 -->|fork| J1[worker consulta 1]
W2 -->|fork| J2[worker consulta 2]
J1 -.error sube.-> W2
W2 -.cancelacion baja.-> J2
style R fill:#cba6f7,color:#11111b
style T fill:#fab387,color:#11111b
style J2 fill:#f38ba8,color:#11111b

Políticas de escucha: qué hacer con el evento que llega a destiempo

takeEvery y takeLatest suelen presentarse como dos atajos parecidos, y no lo son: son respuestas opuestas a una pregunta de diseño concreta, que es qué debe pasar cuando llega un evento mientras el trabajo del anterior sigue en vuelo. takeEvery dice que corran los dos, y por dentro es el bucle de take y fork que ya escribiste. takeLatest dice quédate solo con el último y cancela al obrero en vuelo antes de arrancar el nuevo. takeLeading dice lo contrario, quédate con el primero, e ignora los eventos que lleguen mientras haya trabajo activo. throttle limita la frecuencia dejando pasar uno por ventana de tiempo, y debounce espera a que el ruido pare antes de actuar.

El criterio para elegir no es el gusto sino la naturaleza del efecto. Si el trabajo es idempotente y su resultado se acumula —registrar eventos, marcar elementos como leídos— takeEvery es correcto. Si el trabajo produce un resultado que sustituye al anterior —una búsqueda, la carga de un detalle, el guardado de un borrador— takeEvery es directamente un bug, porque introduce la condición de carrera de la respuesta rancia: dos peticiones en vuelo pueden volver en orden inverso al que salieron, y la vieja pisa a la nueva. takeLatest no es una optimización contra ese fallo; es su solución estructural, porque cancela al obrero anterior y su put de éxito nunca llega a ocurrir.

import { takeEvery, takeLatest, takeLeading, debounce, throttle } from "redux-saga/effects";

export function* watchers() {
  // acumulativo e idempotente: que corran todos
  yield takeEvery("analitica/evento", registrarEvento);
  // el resultado sustituye al anterior: solo el ultimo es valido
  yield takeLatest("busqueda/consultar", buscar);
  // accion no idempotente: ignora los clics repetidos mientras trabaja
  yield takeLeading("pedido/confirmar", confirmarPedido);
  // ruido de teclado: espera a que pare
  yield debounce(300, "busqueda/teclear", sugerir);
  // evento continuo: uno por ventana
  yield throttle(1000, "scroll/movido", medirPosicion);
}
⚠️
takeEvery en una búsqueda no es lento: es incorrecto

El error se camufla porque en desarrollo casi nunca aparece. Con una red rápida las respuestas vuelven en orden y todo parece bien; con una red real, la consulta de la letra a puede tardar más que la de abc y llegar después, dejando en pantalla resultados de un término que el usuario ya abandonó. Ninguna comprobación posterior en el reducer arregla eso de raíz, porque el reducer no sabe cuál era la última consulta pedida salvo que lo apuntes a mano. takeLatest elimina la clase entera de fallo al garantizar que solo hay un obrero vivo por patrón, y esa garantía es semántica, no estadística.

all y race: abanico y carrera

all y race son duales y entre las dos cubren casi toda la combinación de efectos concurrentes. all acepta un array o un objeto de efectos, los corre a la vez y se resuelve cuando todos terminan; si uno falla, falla el conjunto y los hermanos se cancelan, igual que en Promise.all. Es el abanico: pedir tres recursos en paralelo, arrancar todos los watchers de la raíz, esperar a que varias condiciones se cumplan.

race acepta un objeto de efectos, los corre a la vez y se queda con el primero que acabe, cancelando a los demás. Es la disyunción, y de ella salen tres patrones que aparecen en cualquier aplicación seria: el timeout, que es una carrera entre la petición y un delay; la cancelación por evento, que es una carrera entre el trabajo y un take de la acción de cancelar; y el apagado de un proceso de vida larga, que es una carrera entre su bucle y la acción que lo detiene. En los tres, la cancelación del perdedor viene incluida y no hay que escribirla.

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

function* cargarConLimite(id: string) {
  const resultado = yield race({
    datos: call(api.getUsuario, id),
    plazo: delay(5000),
    abortado: take("usuario/cancelar"),
  });
  if (resultado.plazo) yield put({ type: "usuario/timeout" });
  else if (resultado.abortado) yield put({ type: "usuario/abortado" });
  else yield put({ type: "usuario/exito", payload: resultado.datos });
}

function* pantallaInicial() {
  // abanico: los tres a la vez, y si uno falla se cancelan los otros dos
  const [perfil, avisos, ajustes] = yield all([
    call(api.getPerfil),
    call(api.getAvisos),
    call(api.getAjustes),
  ]);
  yield put({ type: "inicio/listo", payload: { perfil, avisos, ajustes } });
}
🌿

fork

Tarea hija atada. No bloquea, devuelve un descriptor, propaga errores hacia arriba y cancelación hacia abajo.

✂️

spawn

Tarea desatada, raíz de su propio árbol. Aislamiento de fallos y supervisión al estilo de los actores.

🔀

all

Abanico. Todos a la vez, termina cuando acaban todos y cae entero si uno falla. Es el Promise.all de las sagas.

🏁

race

Carrera. Gana el primero y el resto se cancela solo. De aquí salen timeouts, abortos y apagados limpios.

📝
Los ayudantes son derivables; el árbol no

takeEvery, takeLatest, throttle y debounce se pueden reimplementar en pocas líneas con take, fork, cancel y delay, y merece la pena escribirlos una vez para desmitificarlos. Lo que no es derivable ni sustituible es el modelo del árbol de tareas: la atadura de fork, el corte de spawn y las reglas de propagación son propiedades del intérprete. Por eso, cuando una saga se comporta de forma inexplicable, la primera pregunta útil casi nunca es qué ayudante usaste, sino de quién eres hijo y quién puede cancelarte.

La concurrencia estructurada convierte la cancelación en una propiedad del árbol, no en una tarea tuya

El sistema de tareas de saga es una implementación de la idea que la comunidad de concurrencia lleva veinte años puliendo bajo el nombre de concurrencia estructurada, la misma que hay detrás de los nursery de Trio, los scopes de Kotlin y los task groups de Swift. Su tesis es que la concurrencia sin estructura —lanzar trabajo al aire y confiar en que alguien lo recoja— produce sistemas donde nadie sabe qué está vivo, y que la cura es exigir que toda tarea tenga un padre y que la vida del hijo esté contenida en la del padre. En cuanto impones esa disciplina, tres problemas que eran responsabilidad del programador pasan a ser propiedades de la estructura: las fugas desaparecen, porque una tarea no puede sobrevivir a su ámbito; la propagación de errores deja de requerir plomería, porque el fallo del hijo llega al padre por construcción; y la cancelación deja de ser un mensaje que hay que reenviar a mano, porque cortar un nodo corta el subárbol entero. Eso explica por qué takeLatest puede resolver la condición de carrera de la respuesta rancia en una palabra donde un thunk necesitaba banderas y contadores: no es que la librería tenga más funciones, es que tiene un invariante. Y explica también por qué spawn debe usarse con cuidado y con intención declarada, porque es el bisturí que corta ese invariante a propósito: cada tarea desatada es un pequeño retorno al modelo sin estructura, aceptable cuando el aislamiento de fallos vale más que la contención, y peligroso cuando se usa por comodidad para acallar un error que subía. Aprender concurrencia con sagas es aprender a pensar en árboles de vida, y esa forma de pensar sobrevive intacta a la librería que la enseñó.

⚔️ Orquesta un bosque de tareas
  1. Lanza dos sagas con fork desde una tercera, haz que la primera falle y observa que el padre muere y arrastra a la segunda.
  2. Repite el experimento cambiando la primera a spawn y comprueba que ahora el fallo queda aislado.
  3. Escribe un supervisor que relance con spawn una saga que falla cada pocos segundos, con espera creciente entre reintentos.
  4. Monta una búsqueda con takeEvery y simula latencias variables para reproducir la respuesta rancia; corrígela con takeLatest.
  5. Sustituye una acción no idempotente por takeLeading y verifica que los clics repetidos ya no duplican el trabajo.
  6. Escribe un race de tres ramas —datos, plazo y acción de cancelar— y provoca cada una de las tres salidas.
  7. Reimplementa takeLatest con take, fork y cancel, sustitúyelo por el tuyo y comprueba que el comportamiento observable no cambia.