Efectos y ciclo de vida: qué muere cuando el estado se vuelve nil
El regalo silencioso de `ifLet`, `ifCaseLet` y `forEach` no es el enrutado sino el desmontaje: cuando el estado hijo se anula o cambia de caso, todos los efectos que ese hijo tenía en vuelo se cancelan solos. Esta lección explica el mecanismo —un ámbito de cancelación implícito ligado a la identidad del dominio hijo—, el momento exacto en que se dispara respecto del orden hijo antes que padre, y sobre todo los límites de la garantía: los efectos que el padre lanza en nombre del hijo, las features de raíz que nunca se anulan, las pestañas que persisten y los descartes que ocurren fuera del reducer. Cerramos con el `TestStore` como detector implacable de todo efecto que sobreviva a su dominio.
Un temporizador no sabe que su pantalla se cerró. Una petición de red tampoco, ni un bucle sobre un stream de notificaciones. En cualquier arquitectura donde la duración de un trabajo asíncrono y la duración del estado que le dio sentido sean dos hechos independientes, la fuga es cuestión de tiempo y de olvido. Los operadores de este nivel toman una decisión distinta y silenciosa: al dar vida a un hijo también asumen la responsabilidad de matarlo, y esa responsabilidad incluye todo lo que el hijo tuviera en marcha. Anular el estado no es limpiar un campo; es terminar una vida, con su temporizador detenido, su petición abortada y su suscripción liberada. Conviene entender exactamente cómo ocurre eso, porque es la garantía sobre la que descansa buena parte de la corrección de una app en TCA, y porque tiene fronteras precisas que hay que saber reconocer.
- Explicar el ámbito de cancelación implícito que
ifLeteifCaseLetasocian al dominio del hijo. - Situar el instante de la cancelación respecto del orden hijo antes que padre dentro de un mismo paso.
- Enumerar los casos que quedan fuera del amparo automático y saber qué hacer en cada uno.
- Usar el
TestStoreexhaustivo como detector de efectos que sobreviven a su dominio.
El ámbito de cancelación implícito
Cuando ifLet corre el reducer hijo y este devuelve un efecto, el operador no lo entrega tal cual al store: lo envuelve en un ámbito de cancelación cuya identidad deriva del propio punto de composición y del dominio del hijo. Es, conceptualmente, un cancellable(id:) que no escribiste. Mientras el estado hijo exista, ese ámbito sigue abierto y los efectos corren con normalidad. En el instante en que el estado pasa a nil, el operador cancela el ámbito entero, y con él todo lo que hubiera dentro.
@Reducer
struct Edicion {
@ObservableState
struct State: Equatable { var texto = ""; var latidos = 0 }
enum Action { case alAparecer, latido, textoCambio(String) }
@Dependency(\.continuousClock) var clock
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alAparecer:
return .run { send in
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.latido)
}
}
case .latido:
state.latidos += 1
return .none
case let .textoCambio(t):
state.texto = t
return .none
}
}
}
}
El padre que monta esta feature con .ifLet(\.$edicion, action: \.edicion) no escribe ni una línea de limpieza. Asigna nil al cerrar y el bucle for await termina, la tarea se cancela y la memoria que retenía se libera. Lo mismo vale para ifCaseLet: salir de un caso equivale a anular ese dominio, así que los efectos del hijo que vivía en él mueren igual. Y lo mismo vale para forEach con la identidad del elemento, como viste en el Nivel 15. Los tres operadores comparten mecanismo porque comparten tesis: la vida del efecto es la vida del estado.
El instante exacto: primero el hijo, después el padre, después la muerte
El orden importa y merece precisión, porque hay una secuencia concreta dentro de un mismo paso del store. Cuando entra una acción, el operador comprueba si el estado hijo existe y, si existe, corre el reducer hijo, que puede devolver efectos nuevos. Solo después corre el reducer del padre, que es quien típicamente decide anular el estado. Y solo cuando el padre termina, el operador vuelve a mirar: si antes había estado y ahora no lo hay, cancela el ámbito.
flowchart TD
A[llega una accion] --> B{el estado hijo existe}
B -->|no| Z[no se muta nada y se avisa]
B -->|si| C[corre el reducer hijo y puede lanzar efectos]
C --> D[corre el reducer del padre]
D --> E{el estado hijo sigue existiendo}
E -->|si| F[los efectos del hijo siguen vivos]
E -->|no| G[se cancela el ambito entero del hijo]
style G fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111bEsa secuencia explica un comportamiento que sorprende la primera vez: un hijo puede lanzar un efecto y ver ese efecto cancelado en el mismo paso, si el padre decide cerrarlo al procesar la misma acción. No es un fallo, es la consecuencia deseada del orden. También explica por qué el orden inverso sería inservible: si el padre corriera primero y anulara el estado, el hijo nunca recibiría el mensaje que motivó su propio cierre y no tendría ocasión de reaccionar a él.
Hay un corolario práctico que conviene fijar. El efecto muere porque el dominio desapareció, no porque la pantalla dejara de verse. Son dos hechos que en el mundo imperativo se confunden constantemente, porque el gancho disponible era siempre visual —un viewWillDisappear, un onDisappear— y por tanto la limpieza quedaba atada a una circunstancia poco fiable: una vista puede desaparecer sin que el estado muera, como al empujar otra pantalla encima, y puede morir sin haberse llegado a mostrar. En TCA el criterio es puramente estructural y no consulta a la interfaz para nada. Si el valor está, el hijo vive; si no está, el hijo murió. Eso hace que la regla se pueda razonar leyendo el reducer, sin abrir la vista y sin saber cómo se presenta.
La cancelación automática es potente y por eso mismo peligrosa cuando se confía en ella más allá de su alcance. Primero, los efectos que el padre lanza en nombre del hijo: viven en el ámbito del padre, que no se anula, y siguen corriendo aunque el hijo desaparezca; si necesitan morir con él, dales un cancellable(id:) explícito y cancélalos al anular. Segundo, las features de raíz, que por definición nunca se vuelven nulas: nada las mata, así que sus efectos de larga vida son responsabilidad tuya. Tercero, las pestañas y pantallas persistentes, cuyo estado sigue existiendo cuando el usuario mira otra cosa; que no se vean no significa que hayan muerto. Y cuarto, los descartes que ocurren fuera del reducer, cuando algún código de vista retira la pantalla sin que el estado se entere: ese es exactamente el desajuste que @Presents existe para impedir, y la razón de preferirlo frente a un opcional suelto en todo lo que se presente.
Para la primera de esas cuatro fugas, la del efecto que el padre lanza en nombre del hijo, el remedio es corto y merece verse escrito, porque es el que más aparece en revisiones de código.
private enum CancelID { case sincronizacion }
case .editarTocado:
state.edicion = Edicion.State(texto: state.titulo)
return .run { send in
for await evento in self.sincronizador.eventos() {
await send(.edicion(.presented(.eventoRemoto(evento))))
}
}
.cancellable(id: CancelID.sincronizacion)
case .edicion(.dismiss):
return .cancel(id: CancelID.sincronizacion)
Ese efecto alimenta al hijo pero pertenece al padre, así que el ámbito implícito no lo alcanza y hace falta el identificador explícito del Nivel 11. La pregunta que resuelve el caso siempre es la misma: ¿quién devolvió este efecto? Si lo devolvió el reducer hijo, morirá con su dominio; si lo devolvió el padre, vive tanto como el padre y es tuyo cancelarlo.
El TestStore como detector de supervivientes
La garantía no se comprueba leyendo el código sino ejecutando la suite, y aquí el rigor del TestStore exhaustivo del Nivel 3 se vuelve un aliado inesperado: falla si al terminar el test queda algún efecto en vuelo. Un efecto que sobrevive a su dominio no es un detalle que se le escape a la prueba; es motivo de fallo.
@Test
func elLatidoMuereConLaEdicion() async {
let reloj = TestClock()
let store = TestStore(initialState: Padre.State()) {
Padre()
} withDependencies: {
$0.continuousClock = reloj
}
await store.send(.editarTocado) {
$0.edicion = Edicion.State()
}
await store.send(.edicion(.presented(.alAparecer)))
await reloj.advance(by: .seconds(1))
await store.receive(\.edicion.presented.latido) {
$0.edicion?.latidos = 1
}
await store.send(.edicion(.dismiss)) {
$0.edicion = nil
}
}
Ese test pasa únicamente porque ifLet canceló el temporizador al anular el estado. Quita la cancelación —o mueve el efecto al ámbito del padre— y la prueba fallará al terminar por tarea pendiente, sin que tengas que escribir aserción alguna sobre fugas. Es la propiedad más valiosa del testing exhaustivo: convierte una clase entera de defectos invisibles en errores de compilación de la suite.
Ámbito ligado al dominio
El operador envuelve los efectos del hijo en un ámbito de cancelación que se cierra cuando el estado desaparece.
Muerte al final del paso
La cancelación se dispara después de que corran hijo y padre, cuando el operador detecta que el dominio ya no está.
Fugas fuera del amparo
Efectos del padre, features de raíz y pestañas persistentes siguen necesitando cancelación explícita.
La suite delata
El TestStore exhaustivo falla ante cualquier tarea en vuelo al terminar. Toda fuga se manifiesta como test rojo.
Durante décadas, el desmontaje ha sido el rincón más costoso del desarrollo de interfaces, y siempre por el mismo motivo estructural: la pieza que arranca el trabajo y la pieza que sabe cuándo el trabajo deja de importar no eran la misma, así que hacía falta un pacto entre ellas —un deinit, un viewWillDisappear, un conjunto de suscripciones que alguien vacía en el momento correcto— sostenido enteramente por la memoria del programador. Cada pacto olvidado producía una fuga, y cada fuga se manifestaba tarde, lejos de su causa y de forma difícil de reproducir. Lo que hacen ifLet e ifCaseLet no es recordarte el pacto: es eliminar la necesidad del pacto, unificando ambas piezas en el operador que da vida al hijo. Quien crea el dominio es quien lo destruye, y como el dominio es un valor, su destrucción es observable, determinista y ocurre en un instante conocido del ciclo del store. La consecuencia es que la corrección deja de depender de la disciplina y pasa a depender de la estructura, que es la única transferencia que escala: un equipo puede olvidarse de cancelar cien veces sin consecuencia, porque no hay nada que olvidar. Pero la misma unificación explica con precisión dónde acaba la garantía, y esa frontera hay que interiorizarla: el amparo alcanza a lo que nace dentro del dominio del hijo, no a lo que el padre lanza en su nombre ni a lo que vive en un dominio que nunca muere. Saber trazar esa línea —qué efecto pertenece a qué dominio y cuánto dura ese dominio— es, en el fondo, el mismo ejercicio de diseño que decidir dónde vive cada pieza de estado. Los efectos no tienen ciclo de vida propio en TCA; heredan el de los datos que les dieron sentido.
- Monta una feature hija con un temporizador de larga vida y preséntala con
@PresentseifLet. Cierra la hoja y confirma con unprintque el temporizador se detiene. - Mueve ese mismo temporizador al reducer del padre, lanzándolo cuando el padre abre la hija. Repite el cierre y observa que ahora sobrevive. Arréglalo con
cancellable(id:)y una cancelación explícita. - Escribe el test del primer caso sin ninguna aserción sobre fugas y comprueba que pasa. Rompe la cancelación a propósito y observa cómo la suite falla por tarea en vuelo.
- Reproduce el escenario de la pestaña persistente: dos pestañas, cada una con su feature de raíz y su sondeo. Mide el consumo y decide qué política de cancelación aplicar.
- Redacta la regla en una frase, con sus excepciones, y contrástala con el Nivel 11: qué garantiza el operador, qué sigue siendo tuyo y cómo distingues uno de otro leyendo un reducer ajeno.