cancelInFlight: un solo efecto vivo por identidad
El parametro cancelInFlight convierte una identidad de cancelacion en una plaza unica: antes de registrar el efecto nuevo, el store da de baja lo que hubiera vivo bajo ese mismo nombre. Esta leccion analiza esa operacion como el establecimiento de un invariante —como mucho un efecto en vuelo por identidad— y todo lo que ese invariante simplifica aguas abajo: el indicador de carga vuelve a ser un booleano honesto, la accion de respuesta recupera el derecho a escribir sin preguntar y la suscripcion a un stream se vuelve idempotente frente a un onAppear que se dispara dos veces. Cubre tambien los casos en que exigir esa unicidad es un error: cargas por elemento en colecciones, subidas que deben completarse todas y efectos de registro que no compiten entre si.
La lección anterior dejó dos operaciones separadas: registrar un efecto bajo un nombre y dar de baja lo que viva bajo ese nombre. Casi siempre las quieres juntas y en ese orden —cancela lo anterior, arranca lo nuevo—, y por eso .cancellable acepta un segundo parámetro que las funde en un solo gesto. Escrito así parece un atajo de comodidad, y es mucho más: cancelInFlight: true no ahorra una línea, establece un invariante. A partir de ese momento, para esa identidad, el sistema garantiza que hay como mucho un efecto vivo. Y como pasa siempre con los invariantes buenos, lo interesante no es lo que impide sino todo lo que permite dejar de comprobar aguas abajo.
- Describir con precisión qué hace
cancelInFlighten el registro del store y en qué orden. - Reconocer el invariante que instaura —como mucho un efecto en vuelo por identidad— y qué comprobaciones vuelve innecesarias.
- Usarlo para hacer idempotentes las suscripciones a streams frente a un ciclo de vida que se repite.
- Identificar los casos donde exigir unicidad es incorrecto y hay que parametrizar la identidad o renunciar a ella.
Una plaza única por nombre
El parámetro se lee casi como su definición: antes de anotar la tarea nueva en el registro, cancela la que estuviera anotada bajo esa misma identidad.
private enum CancelID { case busqueda }
case let .consultaCambiada(texto):
state.consulta = texto
return .run { [texto] send in
await send(.respuesta(try await cliente.buscar(texto)))
}
.cancellable(id: CancelID.busqueda, cancelInFlight: true)
Con esto, el buscador de la lección 1 queda arreglado en un parámetro. Cada pulsación mata la petición anterior y ocupa su plaza; la red solo ve viva, en cada instante, la consulta más reciente. Conceptualmente equivale a devolver .merge(.cancel(id: X), efecto.cancellable(id: X)), pero el store lo hace como una sola operación sobre el registro, sin la ventana en la que ambos efectos podrían coexistir.
sequenceDiagram participant S as Store participant R as Registro participant E1 as Efecto de swif participant E2 as Efecto de swift S->>R: registrar busqueda para E1 R->>E1: vive S->>R: registrar busqueda para E2 con cancelInFlight R->>E1: cancelado, no emite nada R->>E2: ocupa la plaza y vive
Fíjate en la línea que más importa del diagrama: no emite nada. El efecto cancelado no manda una acción de éxito, ni de fallo, ni de cancelación. Desde el punto de vista del reducer, es como si nunca hubiera existido. Esa desaparición limpia es lo que permite razonar sobre el estado sin llevar la contabilidad de lo que quedó a medias.
Y el orden importa tanto como el silencio. Que la baja del anterior ocurra antes de dar de alta al nuevo, y no después, es lo que impide un cruce venenoso: si el registro se hiciera primero, el .cancel posterior encontraría en la plaza al efecto recién nacido y se mataría a sí mismo. Ese fallo —el efecto que se suicida por culpa de su propio predecesor— es exactamente lo que ocurre cuando alguien intenta reproducir el patrón a mano con dos retornos separados en el orden equivocado, y es la razón por la que conviene usar el parámetro en vez de improvisar la secuencia.
El invariante y todo lo que simplifica
Un invariante vale por lo que te ahorra. Este vale bastante, y conviene enumerarlo porque su efecto se nota en sitios que a primera vista no tienen que ver con la cancelación.
La respuesta recupera su inocencia
Si solo puede haber un efecto vivo, la única respuesta posible es la del efecto actual. El guard texto == state.consulta sobra: no hay respuestas obsoletas que filtrar porque no llegan a producirse.
El indicador de carga vuelve a ser un booleano
Con varios efectos vivos, cargando tendría que ser un contador para no apagarse antes de tiempo. Con unicidad garantizada, un Bool describe la realidad sin mentir.
La suscripción se vuelve idempotente
Un onAppear que se dispara dos veces suscribe dos veces al mismo stream y duplica cada evento. Con cancelInFlight, la segunda suscripción sustituye a la primera en lugar de sumarse.
La acción deja de cargar metadatos
Sin necesidad de comparar en el destino, la acción de respuesta no tiene que arrastrar la consulta ni un número de secuencia. El Action vuelve a describir el dominio y no la fontanería.
El caso de la idempotencia merece desarrollo, porque es el que más bugs discretos resuelve. En SwiftUI, el ciclo de vida de una vista no es tan predecible como uno querría: una vista puede aparecer, desaparecer y reaparecer en una navegación por pila, y en ciertas composiciones el onAppear se dispara más veces de las que esperas. Si la acción de aparición abre un stream, cada disparo extra deja un observador de más.
case .alAparecer:
return .run { send in
for await notificacion in centro.mensajes() {
await send(.mensajeRecibido(notificacion))
}
}
.cancellable(id: CancelID.escucha, cancelInFlight: true)
Sin el parámetro, aparecer tres veces deja tres bucles vivos y cada mensaje del centro llega al reducer por triplicado —con lo que eso implica si el reducer inserta en una lista o incrementa un contador—. Con él, la acción alAparecer se vuelve idempotente: ejecutarla una vez o siete deja el sistema en el mismo estado. Es una propiedad muy valiosa en cualquier sistema donde no controlas del todo cuántas veces te van a llamar.
El ahorro de código que produce el invariante se ve mejor comparando las dos versiones del mismo reducer. Sin unicidad, la acción de respuesta se convierte en un pequeño tribunal que dictamina si el dato que le llega merece entrar:
// Sin invariante: cada consumidor arrastra la comprobacion
case let .respuesta(texto, items):
guard texto == state.consulta else { return .none }
state.peticionesVivas -= 1
state.cargando = state.peticionesVivas > 0
state.resultados = items
return .none
// Con invariante: no hay nada que dictaminar
case let .respuesta(items):
state.cargando = false
state.resultados = items
return .none
Cuatro líneas menos y, más importante, un campo menos en el estado y un valor asociado menos en la acción. Ese es el patrón que conviene aprender a reconocer: cuando una comprobación aparece repetida en varios consumidores, casi siempre existe un punto único, más arriba, donde se puede volver innecesaria.
cancelInFlight te protege de suscripciones duplicadas, no de suscripciones huérfanas. Que la nueva sustituya a la vieja no significa que la última se cierre cuando la pantalla muere: para eso hace falta cancelar al desaparecer, o mejor, atar el efecto al ciclo de vida del estado —la lección 5 de este nivel—. Piensa en cancelInFlight como la garantía de no más de uno, y en la cancelación al desaparecer como la garantía de ninguno cuando ya no hace falta. Son ortogonales y casi siempre las necesitas ambas.
Cuando exigir unicidad es un error
El invariante es fuerte, y por eso hay que aplicarlo solo donde de verdad describe el dominio. Preguntarse ¿estos dos efectos compiten por el mismo sitio o son trabajos independientes? casi siempre da la respuesta correcta.
| Situación | Identidad | Por qué |
|---|---|---|
| Búsqueda mientras se escribe | única, cancelInFlight: true |
Solo la última consulta importa; las anteriores son ruido caro. |
| Refresco por gesto de arrastre | única, cancelInFlight: true |
Repetir el gesto debe reemplazar la carga, no acumularla. |
| Carga del detalle de cada fila | paramétrica por ID |
Las filas no compiten: cancelar la de una al cargar otra rompe la lista. |
| Subida de varios adjuntos | paramétrica por adjunto | Todas deben completarse; unicidad perdería archivos en silencio. |
| Envío de analítica | sin identidad | No compiten ni hace falta detenerlas; registrar por registrar sería ruido. |
El error más caro de la tabla es el tercero. Si todas las filas de un forEach registran su carga bajo la misma identidad y encima con cancelInFlight, al desplazarse por la lista cada fila que aparece mata la carga de la anterior, y el usuario ve un rosario de celdas que empiezan a cargar y se quedan a medias sin ningún error. El síntoma es desconcertante justo porque no hay excepción ni fallo: el efecto cancelado, ya lo sabes, no dice nada. La corrección es la de la lección 2 —un caso con valor asociado por elemento— y con ella la unicidad pasa a significar lo que debía significar: como mucho una carga viva por fila.
También hay un límite conceptual que conviene tener presente: cancelInFlight implanta una política de último que llega, gana. Existen dominios donde la política correcta es la contraria, primero que llega, gana —una petición crítica que no debe reiniciarse porque el usuario toque dos veces—, y eso no se expresa con este parámetro sino con una guarda en el estado que ignore la acción si ya hay algo en curso. Elegir entre ambas no es un detalle técnico; es una decisión de producto que el código debe reflejar de forma explícita.
Y una advertencia sobre operaciones que escriben en el servidor. Cancelar una lectura es inofensivo, porque no deja rastro; cancelar una escritura es otra cosa. Una petición cancelada del lado del cliente puede haber llegado igualmente al servidor y haberse ejecutado allí: lo que matas es tu espera de la respuesta, no necesariamente el efecto remoto. Por eso cancelInFlight es la opción natural para búsquedas, sugerencias y refrescos, y una decisión que hay que pensar dos veces para envíos, pagos o borrados, donde lo correcto suele ser impedir el segundo disparo antes que cancelar el primero.
Verificar el invariante, no solo confiarlo
Un invariante que solo existe en la cabeza del que lo escribió dura hasta la siguiente refactorización. El TestStore permite fijarlo como aserción, y el test resultante es corto y contundente: dos acciones seguidas, una sola respuesta.
@Test
func soloSobreviveLaUltimaBusqueda() async {
let llamadas = LockIsolated(0)
let store = TestStore(initialState: Busqueda.State()) {
Busqueda()
} withDependencies: {
$0.clienteBusqueda.buscar = { _ in
llamadas.withValue { $0 += 1 }
return [.demo]
}
}
await store.send(.consultaCambiada("sw")) { $0.consulta = "sw" }
await store.send(.consultaCambiada("swift")) { $0.consulta = "swift" }
await store.receive(\.respuesta) { $0.resultados = [.demo] }
}
La prueba de fuego es el receive solitario. Si el efecto de sw hubiera sobrevivido, el TestStore terminaría con una acción emitida y no afirmada, y fallaría. Es decir: la exhaustividad que el nivel 3 presentó como una exigencia incómoda resulta ser, aquí, el mecanismo que detecta que la unicidad se rompió. No hace falta una herramienta especial para auditar efectos vivos; la que ya tienes falla por defecto cuando sobra alguno.
Lo que hace cancelInFlight cabe en una frase, pero el patrón de razonamiento que ilustra vale para todo el nivel y, en realidad, para todo diseño de sistemas concurrentes. Hay dos maneras de convivir con un estado del mundo indeseable: comprobarlo cada vez que podría hacer daño, o hacerlo imposible. La primera dispersa el conocimiento —cada consumidor tiene que acordarse de la comprobación, y basta uno que la olvide— y crece de forma cuadrática, porque cada nuevo caso hay que reconciliarlo con todos los anteriores. La segunda concentra el conocimiento en un solo lugar, el punto donde se establece el invariante, y a partir de ahí todos los consumidores razonan sobre un mundo más pequeño y más simple, sin saber siquiera que alguien les está protegiendo. Observa el balance concreto: un parámetro booleano en la construcción del efecto elimina un guard en la acción de respuesta, convierte un contador de peticiones vivas en un Bool, permite quitar la consulta de la carga útil de la acción, hace idempotente el onAppear y desaparece toda una familia de bugs de duplicación de eventos que ni siquiera habías escrito todavía. Ninguna de esas cinco simplificaciones parece, vista de cerca, tener que ver con la cancelación; todas se siguen de haber restringido, en un único punto, cuántos efectos pueden existir a la vez. Ahí está la lección de fondo: en concurrencia, la complejidad no vive en las operaciones sino en las combinaciones posibles de operaciones simultáneas, y la palanca más potente que existe no es manejar bien cada combinación, sino reducir cuántas pueden darse. Un invariante es exactamente eso: una promesa que le haces al futuro para que el futuro tenga menos casos que considerar. Y la razón por la que TCA puede ofrecerte esta palanca en un parámetro —en vez de en un manual de buenas prácticas— es que el efecto es un valor con identidad declarada y el store es el único que los ejecuta: hay un sitio, y solo uno, donde la promesa se puede hacer cumplir.
- Toma el buscador con
guard texto == state.consultade la lección 1. AñadecancelInFlight: true, borra elguardy quita la consulta de la carga útil de la acción de respuesta. Verifica en unTestStoreque el comportamiento sigue siendo correcto con dos consultas seguidas. - Convierte
cargandode contador aBooly razona qué propiedad del sistema te autoriza a hacerlo. Luego quitacancelInFlighty comprueba que el booleano vuelve a mentir. - Monta un efecto que itere un stream infinito al aparecer. Dispara
alAparecertres veces sincancelInFlighty cuenta los eventos duplicados; añádelo y repite la cuenta. - En un
forEach, pon a todas las filas la misma identidad concancelInFlight: truey desplázate por la lista. Describe el síntoma y explica por qué no aparece ningún error. Arréglalo con una identidad paramétrica. - Implementa la política contraria —primero que llega, gana— con una guarda de estado que ignore la acción si ya hay algo en curso, y enuncia en qué caso de producto preferirías cada una.