Cancelación: detener una saga en vuelo y limpiar bien
Cancelar una función que está corriendo es imposible en JavaScript; cancelar una descripción que alguien está interpretando es trivial, y toda la cancelación de redux-saga vive en esa diferencia. Esta lección explica el mecanismo real —el intérprete deja de reanudar el generador y le inyecta una señal que dispara los finally— y su vocabulario: cancel sobre una tarea, cancelled para distinguir el final abortado del normal, y el finally como único lugar honesto donde poner la limpieza. Después nombra la ilusión más peligrosa del tema: cancelar la saga no aborta la petición HTTP, que sigue viva salvo que ates la promesa a un AbortController mediante el símbolo CANCEL. Cierra con los dos casos canónicos: el buscador cuyo resultado rancio nunca debe llegar, y el proceso de una pantalla que se desmonta.
En JavaScript no se puede detener una función que está corriendo. No hay interrupción, no hay hilo que matar, no hay forma de arrebatarle el control a un cuerpo de función que ya empezó; por eso cancelar una promesa fue durante años un problema sin solución elegante y por eso los thunks acabaron llenándose de banderas que solo servían para ignorar un resultado que ya había llegado. Una saga cambia el planteamiento por completo, y lo hace sin ninguna magia: como la saga no está corriendo sino suspendida esperando a que el intérprete la reanude, cancelarla consiste sencillamente en no reanudarla nunca más. La cancelación deja de ser una operación sobre la ejecución y pasa a ser una decisión del intérprete, que es exactamente el tipo de cosa que la reificación del primer capítulo prometía volver fácil.
- Entender la cancelación como decisión del intérprete: dejar de reanudar, no interrumpir código en marcha.
- Escribir limpieza correcta con
finallyy distinguir el final abortado del normal concancelled. - Reconocer que cancelar la saga no aborta la petición y atar la promesa con el símbolo
CANCELy unAbortController. - Resolver los dos casos canónicos: el resultado rancio del buscador y el proceso de una pantalla desmontada.
Cancelar es dejar de interpretar
Cuando una tarea recibe cancel, el middleware no aborta nada: marca la tarea como cancelada, cancela recursivamente el efecto en el que estaba suspendida y a todos sus hijos atados, y en vez de reanudar el generador con un valor normal le inyecta una señal interna de cancelación en el punto exacto donde esperaba. El efecto práctico de esa inyección es que el generador se desenrolla como si hubiese llegado un return desde fuera: sale de los bloques activos ejecutando sus finally, y termina. El código que venía después del yield cancelado no se ejecuta jamás, y por tanto ningún put posterior llega al store.
Esa última frase es la propiedad que lo justifica todo. En un thunk, cancelar significaba dejar que el trabajo terminara y luego decidir no usar su resultado, lo que obliga a acordarse de comprobar la bandera en cada punto de escritura y deja abierta la puerta a que un despacho se cuele. En una saga cancelada no hay nada que recordar: los despachos posteriores no ocurren porque el código que los contiene no se ejecuta. Se pasa de una disciplina que el programador sostiene a una garantía que la estructura impone.
Hay una consecuencia que conviene tener presente desde el principio: la cancelación es cooperativa y ordenada, no violenta. La tarea tiene su oportunidad de limpiar, sus hijos la reciben antes que ella termine y el padre queda informado. Nada queda a medio camino en el árbol, y esa pulcritud es lo que permite usar cancelación como herramienta cotidiana en vez de como último recurso.
Hay un matiz que sorprende la primera vez y conviene anticipar. Como la cancelación viaja hacia abajo por el árbol de tareas atadas, cancelar a un padre cancela a sus hijos aunque estos estuviesen a mitad de una operación que a ti te parecía independiente; y al revés, cancelar a un hijo no toca al padre, que sigue su curso y puede quedarse esperando eternamente un join de una tarea que ya murió. Ninguno de los dos comportamientos es un fallo: los dos se deducen de las reglas del capítulo anterior. Cuando una cancelación produzca un efecto que no esperabas, la pregunta correcta casi siempre es de quién eras hijo, no qué efecto usaste.
flowchart TD A[tarea suspendida en un yield] -->|cancel| B[marcar la tarea como cancelada] B --> C[cancelar efecto en curso] B --> D[cancelar hijos atados] C --> E[inyectar senal de cancelacion] E --> F[ejecutar bloques finally] F --> G[tarea terminada sin put posteriores] style B fill:#fab387,color:#11111b style F fill:#89b4fa,color:#11111b style G fill:#a6e3a1,color:#11111b
finally y cancelled: la limpieza que sí ocurre
Como la cancelación desenrolla el generador, el finally es el lugar canónico de la limpieza y funciona igual que en código síncrono: se ejecuta tanto si la saga acabó bien, como si lanzó, como si la cancelaron. Dentro de él puedes seguir rindiendo efectos, que el intérprete atiende aunque la tarea esté en modo cancelación, así que cerrar un canal, despachar una acción de limpieza o soltar un recurso es posible y correcto.
Lo que el finally por sí solo no te dice es por qué estás allí, y a veces la limpieza difiere según el motivo. Para eso está cancelled, un efecto que devuelve verdadero si la tarea llegó al finally por cancelación y falso si terminó de forma normal o por error. El patrón es siempre el mismo y merece la pena memorizarlo, porque una acción de estado cancelado solo tiene sentido en la rama cancelada, mientras que soltar un recurso tiene sentido en las tres.
import { call, put, take, cancelled, cancel, fork, delay } from "redux-saga/effects";
import { api } from "./api";
function* sincronizar(id: string) {
try {
while (true) {
const datos: unknown = yield call(api.sondear, id);
yield put({ type: "sync/datos", payload: datos });
yield delay(5000);
}
} finally {
// Se ejecuta siempre: fin normal, error o cancelacion.
yield call(api.cerrarSesionDeSondeo, id);
if (yield cancelled()) {
// Solo cuando el final fue abortado desde fuera.
yield put({ type: "sync/cancelado", payload: id });
}
}
}
function* controlador() {
const tarea = yield fork(sincronizar, "7");
yield take("sync/detener");
yield cancel(tarea); // el finally de sincronizar corre ahora
}
Un finally que se enreda —que espera acciones, que hace varias llamadas encadenadas, que puede lanzar— convierte un apagado en un segundo proceso con sus propios modos de fallo. Si algo en la limpieza lanza, ese error sube por el árbol como cualquier otro y puede tumbar al padre justo cuando intentabas ordenar la salida. Trata el finally como una rutina de apagado: pocas operaciones, todas idempotentes, y si una es propensa a fallar, envuélvela en su propio try y catch para que un problema al cerrar no se convierta en un problema mayor que el que causó la cancelación.
El mundo no se entera: CANCEL y AbortController
Aquí está la ilusión que más caro se paga. Cancelar una saga detiene la saga; no detiene la petición HTTP que el intérprete ya había lanzado. Esa petición sigue viajando, el servidor sigue trabajando y la respuesta llegará a un fetch que ya no tiene a nadie escuchando. Para la corrección de la interfaz eso basta —el resultado rancio nunca se despacha— pero para el ancho de banda, la batería, la carga del servidor y cualquier efecto que la petición cause al otro lado, no basta en absoluto.
redux-saga expone el puente para arreglarlo: el símbolo CANCEL. Si la promesa que devuelve tu función lleva una propiedad con esa clave y su valor es una función, el middleware la invoca al cancelar el efecto. Colgando ahí el abort de un AbortController la cancelación atraviesa la frontera y llega a la red de verdad. Es un detalle pequeño de escribir y casi siempre olvidado, y es la diferencia entre una cancelación que arregla la pantalla y una que además arregla el sistema.
import { CANCEL } from "redux-saga";
export function getUsuario(id: string) {
const control = new AbortController();
const promesa = fetch(`/api/usuarios/${id}`, { signal: control.signal })
.then((r) => r.json());
// El middleware llamara a esto si cancela el efecto que espera la promesa.
(promesa as any)[CANCEL] = () => control.abort();
return promesa;
}
Los dos casos canónicos
El primero es el buscador. El usuario teclea, cada pulsación lanza una consulta y las respuestas vuelven desordenadas, de modo que un resultado viejo puede pisar a uno nuevo. Con takeLatest el problema desaparece de raíz porque el obrero anterior se cancela antes de arrancar el siguiente y su put nunca ocurre; añadiendo debounce delante evitas además lanzar consultas que nadie quería, y atando CANCEL al AbortController la petición abandonada muere también en la red. Las tres piezas son independientes y resuelven cosas distintas: corrección, coste y cortesía con el servidor.
El segundo es el proceso que pertenece a una pantalla. Un sondeo, una suscripción a un socket, un asistente de varios pasos: todos deben morir cuando la pantalla desaparece, y ninguno debería depender de que un componente se acuerde de avisar. La forma idiomática es hacer que la vida del proceso esté contenida en una carrera: el trabajo compite contra el take de la acción de salida, y cuando esa acción llega, el perdedor se cancela solo y su finally cierra lo que hubiera abierto. El componente solo tiene que despachar al desmontarse; toda la lógica de apagado vive en la saga.
Merece la pena comparar ese patrón con la alternativa de guardar el descriptor de la tarea en algún sitio y llamar a cancel desde fuera. Funciona, pero introduce un registro de tareas vivas que hay que mantener, indexar y limpiar, y ese registro es estado sobre el estado: una tabla paralela que puede desincronizarse del árbol real. La versión con race no guarda nada porque la condición de terminación forma parte del propio proceso, y esa es la forma idiomática. La regla general que se deduce es que la vida de una tarea debería estar escrita dentro de la tarea siempre que sea posible, y solo cuando el que decide matarla no tenga forma de expresarse como acción conviene recurrir al descriptor guardado.
import { race, take, call, put } from "redux-saga/effects";
function* procesoDePantalla(id: string) {
yield race({
trabajo: call(sincronizar, id),
salida: take("pantalla/desmontada"), // gana esta y el trabajo se cancela
});
yield put({ type: "pantalla/apagada", payload: id });
}
cancel
Detiene una tarea y su subárbol atado. No interrumpe código: impide que el generador vuelva a reanudarse.
finally
Único lugar honesto para la limpieza. Corre en fin normal, error y cancelación, y admite efectos dentro.
cancelled
Distingue el final abortado del normal. Gobierna la limpieza que solo tiene sentido cuando te cortaron.
CANCEL
Puente hacia el mundo. Cuelga abort de la promesa y la cancelación llega también a la red.
La cancelación en sagas parece una característica y es en realidad la factura a favor que cobra la decisión del primer capítulo. Un programa escrito como actos no admite cancelación porque no hay nada que cancelar: cuando el acto empezó, ya ocurrió, y lo único que queda es la contabilidad de ignorar su resultado, que es precisamente lo que hacen las banderas, los contadores de petición y los identificadores de última consulta con los que todos hemos parcheado un buscador. Un programa escrito como descripciones sí admite cancelación, porque entre la intención y su realización hay siempre un intérprete, y cancelar es simplemente que el intérprete decida no seguir leyendo. Ahí se ve que la cancelación no es una función de la librería sino una propiedad emergente de haber separado el qué del cómo, la misma separación que hace triviales los tests del próximo capítulo. Pero la moneda tiene su cara amarga, y es la razón de que el símbolo CANCEL exista: la separación solo llega hasta donde llega el intérprete, y la petición HTTP ya lanzada vive al otro lado de esa frontera, en un mundo que no se enteró de tu decisión. Cancelar limpiamente hacia dentro es gratis; cancelar hacia fuera exige que cada recurso externo ofrezca su propio mecanismo de abandono y que tú lo ates a mano. Esa asimetría es el resumen honesto del asunto: reificar te da poder total sobre tu programa y ninguno sobre el mundo, y la ingeniería seria consiste en reconocer dónde está esa línea, en vez de creer que la elegancia de tu modelo alcanza territorios que nunca gobernó.
- Escribe una saga de sondeo con un bucle infinito y cancélala desde otra con
cancel; comprueba con un registro que el bucle deja de emitir. - Añádele un
finallycon unputde limpieza y verifica que se ejecuta al cancelar, no solo al terminar bien. - Distingue con
cancelledla rama abortada de la normal y despacha una acción distinta en cada caso. - Provoca un error dentro del
finallyy observa cómo sube por el árbol; protégelo después con su propiotryycatch. - Monta un buscador con
takeEveryy latencias desiguales, reproduce el resultado rancio y arréglalo pasando atakeLatest. - Ata
CANCELa unAbortControlleren tu función de red y confirma en el panel de red del navegador que la petición cancelada aparece abortada. - Envuelve un proceso de pantalla en un
racecontratakede la acción de desmontaje y comprueba que el socket se cierra sin que el componente sepa nada del apagado.