`.refreshable` en SwiftUI: por qué tu tarea se cancela sola (y cómo evitarlo)
Arturo Rivas Arias
🔄 .refreshable es uno de esos modificadores de SwiftUI que parece trivial hasta que algo falla de forma inexplicable. Lo añades a una lista, le pasas tu función async, y todo funciona en el simulador. Pero en producción, o simplemente bajo condiciones ligeramente distintas, la carga se interrumpe a la mitad, el indicador de refresco desaparece antes de tiempo, o una petición de red lanza un error. El origen del problema casi nunca está donde lo buscas.
📖 Apple introdujo .refreshable en la WWDC21, junto con el modificador .task y el modelo de concurrencia estructurada de Swift. La idea era clara: proporcionar una API nativa para el gesto de pull-to-refresh que funcionara de forma idiomática con async/await. Basta con aplicar el modificador a cualquier vista compatible —List, ScrollView— y SwiftUI gestiona automáticamente el indicador de actividad y espera a que el cierre async termine antes de ocultarlo. Internamente, SwiftUI registra la acción como una RefreshAction que se propaga por el entorno de la vista.
⚙️ El funcionamiento interno es el punto clave. .refreshable no ejecuta el bloque de forma independiente: lo convierte en trabajo estructurado que SwiftUI posee. Esto tiene una consecuencia directa que la documentación oficial no refleja de forma explícita: si la vista que contiene el modificador se reconstruye mientras la tarea está en marcha, SwiftUI puede cancelar esa tarea antes de que finalice. La misma lógica que Apple describió para .task —el trabajo ligado al ciclo de vida de la vista se cancela cuando la vista desaparece o se invalida— aplica aquí.
🚨 El escenario problemático más habitual ocurre cuando el propio bloque de refresco muta el estado que controla el cuerpo de la vista. Imagina una pantalla que muestra episodios de un podcast. El refresco limpia la lista actual y empieza a añadir los episodios según llegan:
// ❌ Patrón problemático: mutaciones de @State durante el refresco
struct PodcastFeedView: View {
@State private var episodes: [Episode] = []
var body: some View {
List(episodes) { episode in
EpisodeRow(episode: episode)
}
.refreshable {
await reloadEpisodes()
}
.task {
await reloadEpisodes()
}
}
func reloadEpisodes() async {
// Esta mutación inmediata provoca un redibujado de la vista...
episodes.removeAll()
let fetched = try? await PodcastService.shared.fetchLatest()
for episode in fetched ?? [] {
// ...y cada append es una nueva oportunidad de redibujado
episodes.append(episode)
if Task.isCancelled {
print("Tarea cancelada durante la carga")
return
}
}
}
}
La llamada a episodes.removeAll() ocurre de inmediato dentro del cierre de .refreshable. Como episodes es @State, SwiftUI redibuja el cuerpo al instante. Ese redibujado puede invalidar el contexto de la tarea que está ejecutando el refresco, cancelándola. Y si la tarea no se cancela ahí, cada episodes.append(episode) es otra mutación que dispara otro redibujado, multiplicando las posibilidades de que ocurra.
🩺 Los síntomas de este problema en producción son característicos. La pantalla se queda a medias: ves que la lista se vacía y solo aparecen algunos elementos antes de que el indicador desaparezca. Las peticiones de red que lanza tu capa de datos se abortan con NSURLErrorCancelled (código -999). A veces el comportamiento es intermitente, lo que hace que las trazas de error no apunten a un lugar claro. La causa real no es la red ni el servidor: es el ciclo de vida de la vista interfiriendo con la concurrencia estructurada.
✅ La solución más limpia consiste en eliminar las mutaciones intermedias. En lugar de modificar @State mientras la carga avanza, se acumula el resultado en una variable local y se asigna de una sola vez al terminar:
// ✅ Fix 1: una única mutación al finalizar la carga
struct PodcastFeedView: View {
@State private var episodes: [Episode] = []
var body: some View {
List(episodes) { episode in
EpisodeRow(episode: episode)
}
.refreshable {
await reloadEpisodes()
}
.task {
await reloadEpisodes()
}
}
func reloadEpisodes() async {
// Toda la carga ocurre en memoria local, sin tocar @State
let fetched = (try? await PodcastService.shared.fetchLatest()) ?? []
// Una sola mutación al final: un único redibujado, sin riesgo de cancelación prematura
episodes = fetched
}
}
Con este patrón, SwiftUI solo recibe una señal de cambio de estado cuando la carga ya ha terminado. No hay redibujados intermedios que puedan interferir con la tarea. El coste es perder las actualizaciones progresivas —el usuario no ve los episodios aparecer de uno en uno—, pero en la inmensa mayoría de los flujos de pull-to-refresh ese comportamiento es perfectamente aceptable y la experiencia resulta más estable.
🔀 Hay casos en los que las actualizaciones progresivas son un requisito real: feeds en tiempo real, descargas con progreso visible, o pantallas donde mostrar contenido parcial mejora la percepción de velocidad. En esos escenarios, la alternativa es encapsular el trabajo en una tarea no estructurada para que sobreviva al redibujado de la vista:
// ✅ Fix 2: tarea no estructurada para actualizaciones progresivas
struct LiveScoreView: View {
@State private var matches: [Match] = []
var body: some View {
List(matches) { match in
MatchRow(match: match)
}
.refreshable {
// await Task { ... }.value mantiene el indicador visible
// mientras el trabajo continúa aunque la vista se redibuje
await Task {
await reloadMatches()
}.value
}
}
func reloadMatches() async {
var updated: [Match] = []
for await match in SportsService.shared.liveMatchStream() {
updated.append(match)
matches = updated // Actualizaciones progresivas seguras aquí
}
}
}
La variante await Task { ... }.value es importante: al hacer await sobre el valor de la tarea nueva, el indicador de refresco permanece visible hasta que el trabajo termina. Si se usa Task { ... } sin await, el indicador desaparece de inmediato porque .refreshable considera que su cierre ha terminado, aunque el trabajo siga ejecutándose en segundo plano. Esa versión sin await desacopla el trabajo completamente, lo que puede ser útil en casos específicos, pero cambia la semántica de forma significativa.
🎯 Elegir entre una solución y otra depende del contexto. El patrón de mutación final única es la opción por defecto cuando la carga puede completarse antes de mostrar resultados: es más predecible, permanece dentro del modelo de concurrencia estructurada y no introduce efectos secundarios difíciles de descubrir. La tarea no estructurada es el recurso cuando la carga progresiva es imprescindible o cuando la arquitectura existente hace muy difícil evitar mutaciones intermedias.
🆕 Con la adopción de @Observable (disponible desde iOS 17), el problema no desaparece: solo cambia de forma. Los tipos marcados con @Observable siguen siendo clases bajo el capó, y SwiftUI sigue observando sus cambios para decidir cuándo redibujar. Si dentro de .refreshable mutas propiedades de un objeto @Observable, el mismo mecanismo de cancelación puede activarse. El marco conceptual es idéntico: cualquier mutación observable durante la ejecución del bloque de refresco es una fuente potencial de cancelación prematura.
// Con @Observable el riesgo persiste si mutamos durante la carga
@Observable
final class EpisodeStore {
var episodes: [Episode] = []
var isLoading = false
func reload() async {
// ❌ Mutar isLoading aquí puede causar el mismo problema
isLoading = true
episodes.removeAll()
// ✅ Mejor: acumular y asignar de una vez
let fetched = (try? await PodcastService.shared.fetchLatest()) ?? []
episodes = fetched
isLoading = false
}
}
🛠️ Depurar este tipo de cancelaciones requiere un análisis explícito. Añadir comprobaciones de Task.isCancelled entre pasos de carga y registrar el punto exacto donde se activa ayuda a confirmar si el problema es el ciclo de vida de la vista o algo en la capa de red. El Memory Graph Debugger de Xcode no ayuda aquí directamente, pero activar los puntos de interrupción de Swift Concurrency en el esquema de depuración puede revelar desde qué contexto se llama a la cancelación. En Instruments, la plantilla Swift Concurrency muestra el árbol de tareas y permite identificar qué tarea padre cancela a qué tarea hija.