Cancelación al desaparecer: efectos que sobreviven a su feature
Un efecto de larga vida no sabe nada de la pantalla que lo lanzo: sigue tictaqueando, escuchando o sondeando aunque el estado que le daba sentido ya se haya descartado. Esta leccion trata esa clase de fuga y su remedio estructural en TCA, que no consiste en recordar cancelar sino en atar el efecto al ciclo de vida del estado: los combinadores ifLet, ifCaseLet y forEach cancelan automaticamente los efectos de un hijo cuando su estado se pone a nil o el elemento sale de la coleccion. Revisamos por que onDisappear no es un lugar fiable donde apoyarse, que fugas quedan fuera del amparo automatico —features de raiz, pestanas que persisten, efectos lanzados por el padre en nombre del hijo, identidades compartidas entre instancias vivas— y como el TestStore actua de detector implacable al fallar por toda tarea en vuelo al terminar.
Un temporizador no sabe que su pantalla ya no existe. Un bucle for await sobre un stream tampoco. Ambos siguen despertándose, emitiendo acciones y manteniendo vivas por retención todas las cosas que capturaron, mucho después de que el usuario haya vuelto atrás y de que el estado que les daba sentido se haya descartado. Es la fuga más común de una arquitectura de efectos, y también la más silenciosa: no hay excepción, no hay bloqueo, solo una batería que baja más rápido, una lista que se duplica al reentrar y un contador que corre al doble de velocidad porque hay dos temporizadores donde debería haber uno. La respuesta de TCA a este problema no es un recordatorio de disciplina —acuérdate de cancelar— sino algo más fuerte: atar la vida del efecto a la vida del estado, para que descartar lo segundo mate lo primero sin que nadie tenga que acordarse de nada.
- Reconocer la fuga de efectos que sobreviven al estado que los originó y sus síntomas característicos.
- Aprovechar la cancelación automática de
ifLet,ifCaseLetyforEachcuando el estado hijo desaparece. - Explicar por qué
onDisappeares un punto de anclaje poco fiable y qué usar en su lugar. - Diagnosticar las fugas que quedan fuera del amparo automático y usar el
TestStorecomo detector.
La vida del efecto y la vida del estado
Empieza por ver la fuga con nitidez. Una pantalla arranca un reloj al aparecer y nadie lo detiene nunca.
case .alAparecer:
return .run { send in
for await _ in clock.timer(interval: .seconds(1)) {
await send(.tic)
}
}
.cancellable(id: CancelID.reloj)
Entras, sales, vuelves a entrar. Ahora hay dos bucles vivos —o uno solo, si pusiste cancelInFlight, pero igualmente eterno— y el contador avanza al ritmo equivocado. Repítelo cinco veces y tendrás cinco. Cada uno retiene la closure, la closure retiene el send, y el send mantiene enganchado el camino hasta el store: la memoria no se libera porque, técnicamente, alguien sigue usándola. El defecto no es que falte una línea, es que la duración del efecto y la duración del estado son, por defecto, dos hechos independientes que nadie ha relacionado.
Vale la pena catalogar los síntomas, porque casi nunca se presentan como una fuga y por eso se diagnostican tarde. El más reconocible es la aceleración: un contador que avanza al doble o al triple, un sondeo que pasa de una llamada por minuto a seis. El segundo es la duplicación: cada mensaje entrante se inserta dos veces en la lista, o una notificación se muestra por partida doble. El tercero es el consumo: la app gasta batería y datos con la pantalla apagada porque un stream sigue despierto. Y el cuarto, el más desconcertante, es el estado zombi: una acción vuelve del pasado, entra en un reducer cuya feature ya no está en pantalla y muta un estado que sigue existiendo en memoria pero que ya nadie mira, hasta que el usuario vuelve a entrar y encuentra la pantalla en una situación que no recuerda haber provocado.
flowchart TB E1[entrada 1 arranca reloj] --> V1[efecto vivo] E2[entrada 2 arranca reloj] --> V2[efecto vivo] E3[entrada 3 arranca reloj] --> V3[efecto vivo] V1 --> ST[store recibe tic por triplicado] V2 --> ST V3 --> ST ST --> B[contador al triple y bateria que baja] style B fill:#f38ba8,color:#11111b
Atar el efecto al estado con ifLet y forEach
Aquí está la pieza que convierte el problema en un no-problema, y que mucha gente usa sin saber que la tiene. Cuando un reducer hijo se integra en el padre con ifLet, ifCaseLet o forEach, esos combinadores no se limitan a enrutar acciones: cancelan automáticamente los efectos del hijo en cuanto su estado deja de existir.
@Reducer
struct Padre {
@ObservableState
struct State: Equatable {
@Presents var detalle: Detalle.State?
var filas: IdentifiedArrayOf<Fila.State> = []
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cerrarDetalle:
state.detalle = nil
return .none
// ...
}
}
.ifLet(\.$detalle, action: \.detalle) { Detalle() }
.forEach(\.filas, action: \.filas) { Fila() }
}
}
Poner state.detalle = nil no es solo vaciar un campo: es el hecho que da de baja todos los efectos que el hijo tuviera en vuelo. Igual con forEach: quitar un elemento de la colección cancela los efectos de ese elemento y solo de ese. La consecuencia práctica es importante y libera de mucha ceremonia: si tu feature vive dentro de un opcional presentado o de una colección gestionada por estos combinadores, no necesitas escribir una acción de desaparición ni un .cancel(id:) de despedida. La condición para que ocurra es la que TCA lleva pidiendo desde el nivel 3 —modelar la navegación y la pertenencia como datos dentro del estado del padre— y esta es una de sus recompensas menos anunciadas: cuando quién existe es un dato, dejar de existir es un evento que el sistema puede observar y sobre el que puede actuar.
Estado hijo presentado
ifLet e ifCaseLet cancelan lo del hijo cuando su estado pasa a nil o el caso deja de estar activo. No escribes nada: el ancla es el propio valor.
Elemento de una colección
forEach cancela los efectos del elemento retirado y respeta los de sus hermanos. Es la cancelación de grano fino que una identidad compartida nunca podría dar.
Feature de raíz o pestaña
Nada la descarta, así que nada cancela por ti. Aquí sí hace falta una acción de salida que devuelva .cancel(id:), y conviene que sea la única excepción de tu app.
Ligado a la vista
El modificador task ata la tarea al montaje de la vista. Correcto cuando el efecto solo tiene sentido con la pantalla delante, incorrecto cuando debe sobrevivir a un modal encima.
Si el efecto pertenece a una feature presentada u hospedada en una colección, no cancelas nada: el combinador lo hace. Si el efecto pertenece a una feature raíz o a una pestaña que nunca se descarta, cancélalo explícitamente al salir. Y si el efecto solo debe vivir mientras la vista está en pantalla —una animación, una suscripción cara—, átalo a la vista con el modificador task, que cancela su tarea al desmontarse. Elegir mal el ancla es la causa de la mayoría de estas fugas.
Por qué onDisappear no es un buen ancla
La tentación evidente es cancelar en el gemelo de onAppear. Es frágil por tres motivos independientes, y conviene conocerlos porque el fallo resultante es difícil de atribuir. Primero, no es fiable: en contenedores perezosos, una celda que sale del área visible recibe onDisappear sin que su estado haya desaparecido, y cancelarías un efecto que sigue siendo necesario. Segundo, no es exhaustivo: hay configuraciones de navegación y desmontajes bruscos donde el aviso llega tarde o no llega, y confiar en él deja la fuga intacta justo en los caminos raros, que son los que nadie prueba a mano. Tercero, y más de fondo: onDisappear habla de la vista, y lo que quieres atar es la feature. Son dos ciclos de vida distintos y solo coinciden por casualidad.
.task { await store.send(.task).finish() }
Cuando de verdad quieres el ciclo de vida de la vista, esa es la forma honesta de pedirlo: task arranca al aparecer y cancela su tarea al desmontarse, y el .finish() mantiene la tarea abierta mientras el efecto siga vivo. Sirve para suscripciones que solo tienen sentido con la pantalla delante. Para todo lo que deba sobrevivir a un cambio de pestaña o a una presentación modal por encima, el ancla correcta es el estado, no la vista.
La pregunta que desambigua los tres anclajes es siempre la misma, y conviene formularla en voz alta antes de escribir el efecto: ¿de qué depende que este trabajo siga teniendo sentido? Si depende de que exista un dato —un detalle abierto, una fila en la lista—, el ancla es ese dato y los combinadores se ocupan. Si depende de que el usuario esté mirando —una animación, un vídeo, una suscripción cara que no aporta nada en segundo plano—, el ancla es la vista y el modificador task es la respuesta. Y si no depende de nada porque la feature es la raíz de la app, entonces eres tú quien debe decidir explícitamente cuándo deja de tener sentido, porque no hay ningún acontecimiento del sistema que lo señale por ti.
Fugas típicas y cómo se ven
| Fuga | Síntoma | Remedio |
|---|---|---|
| Reloj o stream en una feature raíz | Eventos duplicados tras varias visitas | Cancelar explícitamente en la acción de salida |
| Pestaña que conserva su estado | El sondeo sigue en segundo plano | Atar el efecto a la pestaña activa o a task |
| Efecto lanzado por el padre para el hijo | Sigue vivo cuando el hijo ya es nil |
Devolverlo desde el hijo, para que ifLet lo cubra |
| Identidad compartida entre instancias vivas | Una instancia apaga el efecto de la otra | Parametrizar el CancelID con el ID de la instancia |
Bucle síncrono sin await |
La cancelación no surte efecto | Comprobar Task.isCancelled dentro del bucle |
La tercera fila es la más sutil y la que más cuesta ver en una revisión de código. Si el padre, al presentar el detalle, devuelve él mismo el efecto que carga los datos del detalle, ese efecto queda registrado en el ámbito del padre y ifLet no lo alcanza: descartar el hijo no lo mata, porque nunca fue del hijo. El arreglo es de responsabilidad, no de sintaxis: el efecto de una feature debe devolverlo esa feature. Cuando la propiedad de los efectos coincide con la propiedad del estado, la cancelación automática cubre todo el terreno; cuando se desalinean, aparecen justo los huecos que la tabla enumera.
La cuarta fila enuncia el principio general de todo el nivel, aplicado a la coexistencia: una identidad solo distingue lo que es capaz de distinguir. Dos instancias de la misma feature que puedan estar vivas a la vez bajo el mismo ámbito necesitan identidades distintas, y la forma de conseguirlo es incorporar al CancelID aquello que las diferencia —el ID del elemento, el identificador del documento abierto— tal como viste en la lección 2.
Y hay una red de seguridad que conviene aprender a leer como diagnóstico y no como molestia. El TestStore falla al terminar un test si queda cualquier efecto en vuelo, con un mensaje que nombra el efecto sin consumir. Ese fallo es el detector de fugas: un test que ejercite entrar, hacer algo y salir revienta si tu feature deja algo corriendo, y revienta en tu máquina, en segundos, en vez de reportarse como un consumo de batería anómalo tres versiones después.
@Test
func elDetalleNoDejaNadaVivo() async {
let clock = TestClock()
let store = TestStore(initialState: Padre.State()) {
Padre()
} withDependencies: { $0.continuousClock = clock }
await store.send(.abrirDetalle) { $0.detalle = Detalle.State() }
await store.send(.detalle(.presented(.alAparecer)))
await clock.advance(by: .seconds(1))
await store.receive(\.detalle.presented.tic) { $0.detalle?.segundos = 1 }
await store.send(.cerrarDetalle) { $0.detalle = nil } // aqui muere el reloj
}
Si borraras el .ifLet de la integración, ese último send dejaría el temporizador con vida y el test fallaría al terminar. Merece la pena interiorizar la implicación: la cancelación al desaparecer no es una propiedad que se comprueba mirando el consumo de batería en un dispositivo, es una propiedad que se afirma en un test unitario de doce líneas y que la integración continua vigila en cada commit.
Detrás de la cancelación automática de ifLet y forEach hay una idea que va mucho más allá de evitar fugas, y que retrospectivamente justifica el precio que TCA cobra en el nivel de navegación. En la mayoría de arquitecturas de interfaz, que una pantalla deje de existir es un suceso del sistema de vistas: ocurre en el marco de UIKit o SwiftUI, se comunica con retrollamadas de ciclo de vida más o menos fiables, y el modelo se entera —si se entera— porque alguien escribió a mano el aviso. La lógica de negocio queda así subordinada a un ciclo de vida que no controla y que no puede inspeccionar. TCA rompe esa subordinación al insistir en que la presentación sea un valor: si el detalle está abierto porque state.detalle no es nil, entonces cerrarlo no es un gesto de la vista sino una mutación de estado, y una mutación de estado es algo que el reducer ve, que el test reproduce y sobre lo que la arquitectura puede colgar comportamiento. La cancelación de los efectos del hijo es precisamente ese comportamiento colgado: no es una función especial que alguien invoca, es una consecuencia que se deduce del hecho observable de que un valor pasó a nil. Fíjate en la inversión completa que esto supone. En el modelo habitual, la vida de los recursos depende de que el programador recuerde liberarlos en el sitio correcto, y ese recuerdo falla justo en los caminos infrecuentes: el desmontaje brusco, la navegación profunda, el error a mitad de flujo. En el modelo de TCA, la vida de los recursos se deriva mecánicamente de la forma del estado, y los caminos infrecuentes están cubiertos por la misma regla que los frecuentes porque todos, sin excepción, pasan por poner un valor a nil o sacar un elemento de una colección. Es la misma clase de victoria que consiguió ARC frente a retain y release manuales, o defer frente a los goto cleanup de C: no se trata de recordar mejor, se trata de que ya no haya nada que recordar. Y el precio a pagar —modelar la navegación como datos, aceptar que el estado dicte lo que existe— resulta ser el mismo que ya habías pagado por poder testear el flujo entero. Una vez más, la ceremonia que TCA cobra por adelantado se devuelve en forma de propiedades que en otras arquitecturas hay que sostener a fuerza de vigilancia.
- Escribe una feature con un temporizador que arranque en
alAparecery no se cancele nunca. Preséntala, ciérrala y vuelve a presentarla varias veces; instrumenta elticcon un contador global y confirma la multiplicación. - Integra esa feature con
.ifLet(\.$detalle, action: \.detalle)y comprueba que poner el destino anilmata el efecto sin que escribas ningún.cancel(id:). - Escribe un test de ciclo de vida completo —presentar, avanzar el reloj, descartar— y verifica que el
TestStoredeja de quejarse de tareas en vuelo. Luego rompe la integración a propósito y lee el mensaje de fallo. - Reproduce la fuga del padre: lanza desde el padre el efecto de carga del hijo, descarta al hijo y observa que la respuesta sigue llegando. Muévelo al hijo y repite.
- Pon dos instancias de la misma feature vivas a la vez con un
CancelIDestático compartido y describe qué le pasa al efecto de la primera. Parametriza la identidad y confirma que ambas conviven. - Elige, para tres efectos reales de tu app, cuál de los tres anclajes les corresponde —estado hijo, cancelación explícita o modificador
task— y justifica cada elección en una frase.