wandres.dev
CANCELACIÓN · debounce y throttle

cancellable y cancel: darle nombre a un efecto

Para poder detener un efecto hay que poder referirse a el, y esa referencia no puede ser la Task concreta que lo ejecuta. El operador `.cancellable(id:)` registra el efecto en el store bajo una identidad Hashable, y `.cancel(id:)` devuelve un efecto cuyo unico trabajo es dar de baja lo que este vivo bajo esa identidad. Esta leccion explica el registro de cancelacion como un diccionario de identidades a tareas, por que el patron `CancelID` con un enum privado evita colisiones que una cadena de texto no evita, como construir identidades por elemento en colecciones, y que significa exactamente cancelar en Swift: una cooperacion, no una interrupcion, donde la tarea muere en su proximo punto de suspension y el CancellationError se descarta en silencio.

⏱ 18 min

Cancelar exige, antes que nada, poder señalar. Y el candidato obvio —la Task que el runtime creó— es justo el que no sirve: esa tarea nace dentro del store, vive fuera del reducer y desaparece sola; el reducer, que es una función pura de estado y acción, no la ve ni debería verla. La solución de TCA es desplazar el problema al único terreno donde el reducer se mueve con soltura, el de los valores: al efecto se le adjunta una identidad —cualquier valor Hashable— y el store mantiene un registro que asocia identidades con tareas vivas. A partir de ahí, cancelar deja de ser una operación sobre un objeto concurrente y se convierte en lo que el reducer sabe hacer: devolver un valor que dice qué nombre hay que dar de baja.

🎯 Al terminar esta lección sabrás
  • Registrar un efecto bajo una identidad con .cancellable(id:) y entender qué guarda el store al hacerlo.
  • Detener ese efecto desde otra acción con .cancel(id:), incluso desde una rama del reducer que no lo lanzó.
  • Aplicar el patrón CancelID con un enum privado y construir identidades por elemento en colecciones.
  • Explicar qué significa cancelar en Swift: una señal cooperativa que actúa en el próximo punto de suspensión.

.cancellable(id:): registrar el efecto bajo un nombre

El operador se encadena al efecto y devuelve otro efecto, envuelto. Cuando el store lo ejecute, además de arrancar la tarea, la anotará en su registro bajo la identidad que le diste; cuando la tarea termine por su cuenta, la desanotará.

@Reducer
struct Busqueda {
  @Dependency(\.clienteBusqueda) var cliente
  private enum CancelID { case peticion }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .buscarPulsado:
        state.cargando = true
        return .run { [consulta = state.consulta] send in
          await send(.respuesta(try await cliente.buscar(consulta)))
        }
        .cancellable(id: CancelID.peticion)

      case let .respuesta(items):
        state.cargando = false
        state.resultados = items
        return .none

      case .cancelarPulsado:
        state.cargando = false
        return .cancel(id: CancelID.peticion)
      }
    }
  }
}

Repara en el detalle de la captura [consulta = state.consulta]: el estado es inout y no puede cruzar la frontera de una closure asíncrona, así que el valor se copia antes de que el efecto exista. Es la misma disciplina de siempre —el efecto se construye con datos, no con acceso al estado vivo— y aquí resulta doblemente sana, porque un efecto cancelable puede sobrevivir varios ciclos del reducer y leer estado mutable desde dentro sería una carrera de otra clase.

flowchart LR
A[accion buscarPulsado] --> R[reducer devuelve Effect]
R --> C[cancellable con id peticion]
C --> S[store arranca la Task]
S --> REG[registro identidad a tarea viva]
A2[accion cancelarPulsado] --> CN[Effect punto cancel id peticion]
CN --> REG
REG --> K[la tarea se da de baja y muere]
style REG fill:#f9e2af,color:#11111b
style K fill:#f38ba8,color:#11111b

Ese registro es el corazón del mecanismo y conviene imaginarlo con precisión: un diccionario de AnyHashable a tareas vivas, alojado en el store y protegido para acceso concurrente. .cancellable(id:) inserta; el final natural del efecto elimina; .cancel(id:) busca y cancela. Nada de esto asoma al reducer, que sigue siendo una función pura: lo único que él manipula son valores que nombran entradas de ese diccionario.

.cancel(id:): dar de baja desde cualquier parte

.cancel(id:) construye un Effect como cualquier otro, y de ahí se derivan tres propiedades que se usan a diario. Se puede devolver desde una rama del reducer que no tiene nada que ver con la que lanzó el efecto, porque la identidad es un valor compartido y no una referencia capturada. Se puede combinar: .merge(.cancel(id: CancelID.reloj), .cancel(id: CancelID.sondeo)) apaga dos cosas a la vez, y .merge(.cancel(id: X), otroEfecto) apaga una y arranca otra en el mismo retorno. Y es idempotente: cancelar una identidad que no tiene nada vivo detrás no falla ni avisa, simplemente no hace nada, lo que te libera de comprobar antes si hay algo que cancelar.

case .pantallaDesaparecio:
  return .merge(
    .cancel(id: CancelID.reloj),
    .cancel(id: CancelID.sondeo)
  )

Hay una variante de grano más fino para cuando la cancelación debe ocurrir dentro de un efecto y no como retorno del reducer: withTaskCancellation(id:cancelInFlight:), la primitiva sobre la que está construido el propio .cancellable. Envuelve un fragmento async con la misma semántica de identidad, y sirve para el caso en que un solo .run hace varias cosas y solo una de ellas debe quedar bajo control externo. Es infrecuente y conviene que lo siga siendo: mientras el ciclo de vida del efecto coincida con el ciclo de vida de la tarea, .cancellable en el retorno es más legible y más fácil de auditar.

El patrón CancelID: identidades que no colisionan

La firma admite cualquier Hashable, y esa generosidad es una trampa. Si identificas con "busqueda", ese literal es igual a sí mismo en toda la aplicación: dos features distintas que usen la misma cadena comparten entrada en el registro, y cancelar una apaga la otra. El fallo es silencioso, aparece solo cuando ambas pantallas están vivas a la vez y no lo detecta ningún compilador. La convención de la comunidad lo cierra de raíz.

private enum CancelID { case peticion, sugerencias, reloj }

Funciona por una razón de tipos, no de nombres: dos enum declarados en features distintas son tipos distintos, aunque ambos se llamen CancelID y tengan un caso peticion. Al meterlos en un AnyHashable, la identidad del tipo forma parte de la comparación, de modo que Busqueda.CancelID.peticion nunca es igual a Perfil.CancelID.peticion. El private refuerza la disciplina impidiendo que nadie de fuera cancele tus efectos, y el enum sin valores asociados da, gratis, Hashable sintetizado y exhaustividad al leer el archivo: los efectos de larga vida de la feature están enumerados en una sola línea.

🏷️

Enum privado, sin casos con datos

La identidad por defecto. Un tipo por feature, un caso por efecto de larga vida. Gratis Hashable, imposible de colisionar entre módulos, y sirve de inventario legible.

🔢

Identidad paramétrica por elemento

En colecciones, un solo nombre no basta: cada fila necesita el suyo. Un caso con valor asociado —case carga(Fila.State.ID)— genera una identidad distinta por elemento sin multiplicar tipos.

🚫

Cadenas de texto

Cómodas y traicioneras: dos módulos que escriban el mismo literal comparten registro. El error no se manifiesta hasta que las dos features coexisten en pantalla.

🧩

Struct Hashable a medida

Cuando la identidad combina varias dimensiones —usuario y sección, por ejemplo—, un struct Hashable explícito documenta mejor esa combinación que un caso con tres valores asociados.

El caso paramétrico merece una mirada extra, porque es donde más se equivoca la gente. Dentro de un forEach, si todas las filas registran su efecto bajo la misma identidad, la segunda fila que cargue matará el efecto de la primera —o, con cancelInFlight, se lo comerá sin avisar—. La identidad tiene que incluir aquello que distingue a la instancia.

private enum CancelID: Hashable { case carga(Fila.State.ID) }

case let .fila(id, .aparecer):
  return .run { send in
    await send(.fila(id, .datos(try await cliente.detalle(id))))
  }
  .cancellable(id: CancelID.carga(id))

Qué significa cancelar de verdad

Cancelar en Swift no interrumpe nada por la fuerza: marca la tarea, y la tarea muere en su siguiente punto de suspensión, cuando el await correspondiente lanza CancellationError. Esto tiene dos consecuencias prácticas. La primera es que un efecto que hace trabajo síncrono largo —un bucle de cálculo sin await— no se entera de que lo cancelaron; si vas a ocupar la CPU, comprueba Task.isCancelled o llama a try Task.checkCancellation() de vez en cuando. La segunda es que la muerte no es instantánea: entre el .cancel(id:) y el último aliento del efecto hay un intervalo, breve pero real, y durante él la tarea sigue existiendo.

return .run { send in
  for pagina in 0..<100 {
    try Task.checkCancellation()
    await send(.pagina(try await cliente.pagina(pagina)))
  }
}
.cancellable(id: CancelID.descarga)

Falta una pieza de comportamiento que sorprende y es deliberada: cuando un efecto muere por cancelación, el catch: de .run no se ejecuta. TCA distingue el CancellationError de un fallo real y lo descarta en silencio, porque una cancelación no es un error del dominio: nadie quiere pintar una alerta de red porque el usuario cambió de pantalla. La consecuencia de diseño es limpia: un efecto cancelado no emite ninguna acción, ni de éxito ni de fallo, y por tanto no puede tocar el estado. Si necesitas que el estado registre la interrupción —apagar un indicador, marcar algo como abortado—, hazlo en el reducer, en la misma rama que devuelve el .cancel(id:), que es exactamente donde consta la intención.

La identidad de un efecto es lo que lo saca del mundo de las referencias

Merece la pena detenerse en la elegancia del truco, porque resuelve una tensión que parecía irresoluble. Un reducer es una función pura de (inout State, Action) a Effect: no puede guardar un puntero a una tarea, no puede consultar si sigue viva, no puede llamarle a un método. Y sin embargo, cancelar es por naturaleza una operación sobre un objeto con identidad y ciclo de vida. La salida no fue relajar la pureza, sino reificar la referencia: en vez de sostener la tarea, se sostiene un valor que la nombra, y se delega en el runtime el diccionario que traduce nombres a tareas. El reducer nunca toca lo mutable; solo emite símbolos. Es la misma jugada que hace el propio Effect con el trabajo asíncrono —describir en vez de ejecutar— aplicada una vuelta más arriba, al control de lo ya descrito. Y esa reificación paga dividendos que van mucho más allá de detener una búsqueda. Al hacerse valor, la identidad se vuelve componible: se puede parametrizar por el ID de una fila, guardar en el estado, pasar de un padre a un hijo o cancelar en bloque combinando efectos. Se vuelve testeable: el TestStore sabe, sin conocer una sola Task, qué hay vivo y qué se dio de baja, y falla si al terminar queda algo en vuelo. Y se vuelve localizable: como la identidad está declarada en un enum privado junto al reducer, el inventario de todo lo que esa feature puede dejar corriendo cabe en una línea que se lee en dos segundos. Compáralo con la alternativa habitual en otras arquitecturas —guardar AnyCancellable en propiedades de una clase, o Task sueltas que alguien tiene que acordarse de guardar y anular en deinit— y verás que el cambio no es de sintaxis: la disciplina que allí depende de la memoria del programador, aquí la sostiene el sistema de tipos y la comprueba el runtime.

⚔️ Construye y rompe un registro de cancelación
  1. Escribe una feature con un efecto de larga vida —un stream o un temporizador— marcado con .cancellable(id:) y una acción que lo detenga con .cancel(id:). Verifica en un TestStore que sin el .cancel el test falla por tarea en vuelo.
  2. Sustituye el enum CancelID por el literal "peticion" en dos features distintas, ponlas en pantalla a la vez y comprueba que cancelar una apaga la otra. Vuelve al enum y explica por qué la colisión desaparece.
  3. Monta un forEach de filas donde cada una carga su detalle. Usa primero una identidad única para todas y observa el destrozo; arréglalo con un caso paramétrico carga(id).
  4. Escribe un efecto con un bucle síncrono de varios segundos sin await y cancélalo. Confirma que no se detiene; añade try Task.checkCancellation() dentro del bucle y repite.
  5. Pon un catch: en un .run cancelable que envíe una acción de fallo. Cancélalo y comprueba que esa acción nunca llega. Justifica por qué es la decisión correcta.