El ciclo de vida de las vistas en SwiftUI: cuándo se dispara onAppear de verdad
Arturo Rivas Arias
🎭 onAppear es uno de esos modificadores que parece trivial hasta que deja de comportarse como esperas. En configuraciones sencillas funciona exactamente como esperas: la vista aparece, onAppear se dispara; desaparece, onDisappear hace lo propio. El problema llega cuando añades esa lógica en un TabView, en una pila de navegación o en una lista de miles de filas, y de repente el comportamiento cambia de forma que no cuadra con el modelo mental que tenías.
🔑 La clave para entender todo esto está en redefinir una sola palabra: “aparece”. En SwiftUI, una vista puede estar viva en la jerarquía sin estar visible en pantalla. El framework mantiene dos sistemas completamente independientes: el tiempo de vida del nodo en el grafo de atributos, y la visibilidad en pantalla. @State está ligado al primero. onAppear y onDisappear responden al segundo. Que a veces coincidan no significa que sean lo mismo.
🧱 Cada vista tiene un nodo en el AttributeGraph, la estructura interna que SwiftUI usa para rastrear dependencias y almacenar estado. Cuando ese nodo se crea por primera vez, @State recibe su valor inicial. Cuando se destruye, ese estado desaparece para siempre. Este ciclo ocurre exactamente una vez por identidad de vista. La visibilidad, en cambio, puede cambiar muchas veces durante la vida de ese mismo nodo: una pestaña que se oculta y se muestra, una vista cubierta por un push que vuelve al hacer pop.
📋 El caso más simple, el que aparece en todos los tutoriales, es una vista detrás de una condición if. Cuando la condición pasa a false, el nodo se destruye; cuando vuelve a true, se crea uno nuevo. Aquí el tiempo de vida y la visibilidad son el mismo evento, por eso parece tan predecible. Si escribías texto en un TextField dentro de esa vista condicional, al ocultarla y mostrarla de nuevo el texto habrá desaparecido: el @State fue destruido con el nodo.
📑 TabView es donde la mayoría de los desarrolladores topan con este comportamiento por primera vez. Desde iOS 18, las pestañas se construyen en modo lazy: solo se crea el nodo de una pestaña la primera vez que el usuario la visita. Al cambiar de pestaña, onDisappear se dispara en la pestaña activa y onAppear en la nueva, pero los nodos de ambas permanecen vivos. Si tenías texto escrito en un campo de la primera pestaña, seguirá ahí cuando vuelvas, porque el @State nunca fue destruido. En iOS 17 el comportamiento era el contrario: todas las pestañas se construían de manera anticipada al arrancar la app, lo que significaba que el onAppear de una pestaña en segundo plano se disparaba incluso antes de que el usuario la hubiera visitado.
🗺️ NavigationStack sigue una lógica diferente. Cuando navegas hacia una vista de detalle, la vista raíz pasa a onDisappear pero su nodo permanece en el grafo. Cuando haces pop de vuelta, la vista de detalle recibe onDisappear y su nodo sí se destruye. Si vuelves a navegar hacia esa misma vista de detalle, se crea un nodo completamente nuevo y el @State empieza desde cero. El mismo onDisappear, dos contenedores distintos, comportamientos opuestos en cuanto al tiempo de vida del estado.
📜 Las listas y los LazyVStack añaden otra capa de complejidad porque operan fila a fila. Solo existen nodos para las filas que están en pantalla o cerca de serlo. Al hacer scroll, las filas que entran en el área visible reciben onAppear y las que salen reciben onDisappear. SwiftUI puede destruir el nodo de una fila lejana para liberar memoria, lo que significa que cualquier @State local de esa fila —un estado de que la celda está expandida, por ejemplo— puede reiniciarse al volver a ella. Para estado que deba sobrevivir al scroll, el lugar correcto es el modelo de datos, no la vista.
// ❌ El estado local de cada fila puede perderse al hacer scroll
struct NotificationRow: View {
let notification: AppNotification
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading) {
Text(notification.title)
.onTapGesture { isExpanded.toggle() }
if isExpanded {
Text(notification.body)
}
}
}
}
// ✅ El estado de expansión vive en el modelo, no en la vista
struct NotificationsViewModel: Observable {
var notifications: [AppNotification] = []
var expandedIDs: Set<UUID> = []
func toggle(_ id: UUID) {
if expandedIDs.contains(id) {
expandedIDs.remove(id)
} else {
expandedIDs.insert(id)
}
}
}
🏗️ El init de una vista no es un evento del ciclo de vida. Esta confusión es frecuente y puede provocar crashes. SwiftUI evalúa el body del padre y construye las estructuras de las vistas hijas como parte del pase de renderizado, pero eso no implica que el nodo exista aún ni que onAppear haya ocurrido. El orden es siempre el mismo: primero se ejecuta body, después onAppear, después task. Acceder en body a un array que esperas poblar en onAppear provoca un crash garantizado porque el array sigue vacío en el momento en que body lo lee.
// ❌ Crash: body intenta leer datos que onAppear todavía no ha cargado
struct EventListView: View {
@State private var events: [CalendarEvent] = []
var body: some View {
Text(events[0].title) // 💥 Index out of range
.onAppear {
events = CalendarEvent.loadUpcoming()
}
}
}
// ✅ El body maneja el estado vacío inicial
struct EventListView: View {
@State private var events: [CalendarEvent] = []
var body: some View {
Group {
if events.isEmpty {
ProgressView("Cargando eventos…")
} else {
Text(events[0].title)
}
}
.onAppear {
events = CalendarEvent.loadUpcoming()
}
}
}
🔁 En producción, el error más común derivado de todo esto es disparar una petición de red en cada cambio de pestaña. Como onAppear se ejecuta cada vez que la vista se hace visible, sin una lógica adecuada estarás haciendo fetch aunque los datos ya estén cargados. El patrón más robusto es usar un enum de estado de carga que impida relanzar la operación si ya está en curso o completada.
enum FetchState: Equatable {
case idle, loading, loaded, failed(String)
}
struct UserProfileView: View {
@State private var fetchState: FetchState = .idle
@State private var profile: UserProfile?
var body: some View {
Group {
switch fetchState {
case .idle, .loading:
ProgressView()
case .loaded:
if let profile {
ProfileContent(profile: profile)
}
case .failed(let message):
Text("Error: \(message)")
}
}
.task {
guard fetchState == .idle else { return }
fetchState = .loading
do {
profile = try await UserAPI.shared.fetchProfile()
fetchState = .loaded
} catch {
fetchState = .failed(error.localizedDescription)
}
}
}
}
🗓️ A veces quieres que cierta lógica se ejecute en cada aparición —refrescar una marca de tiempo, comprobar permisos— pero que la carga de datos ocurra solo la primera vez. @State es la herramienta correcta para esa distinción: como está ligado al tiempo de vida del nodo, un flag hasLoaded sobrevive a los cambios de pestaña pero se reinicia si el nodo se destruye, como al hacer pop en una NavigationStack.
struct CalendarDayView: View {
let date: Date
@State private var hasLoaded = false
@State private var entries: [JournalEntry] = []
var body: some View {
List(entries) { entry in
Text(entry.content)
}
.task {
if !hasLoaded {
entries = await JournalStore.shared.entries(for: date)
hasLoaded = true
}
// Esto sí se ejecuta en cada aparición
await JournalStore.shared.markViewed(date)
}
}
}
🧭 El modelo mental correcto no es una secuencia lineal, sino dos líneas de tiempo paralelas e independientes. El tiempo de vida del nodo corre desde su creación hasta su destrucción, y dentro de él @State existe de forma continua. La visibilidad es una señal que sube y baja varias veces a lo largo de esa misma línea. onAppear y onDisappear pertenecen a la segunda línea; @State pertenece a la primera. Contenedores como TabView o NavigationStack tienen reglas distintas sobre cuándo destruyen los nodos, y hasta que no las conoces, el comportamiento parece arbitrario cuando en realidad es perfectamente consistente.
👨💻 Una vez que separas visibilidad de tiempo de vida, muchos comportamientos que antes parecían bugs se convierten en características predecibles. Las llamadas duplicadas a red, el estado que sobrevive donde no debería y desaparece donde sí debería: todo tiene una explicación directa en estas dos líneas de tiempo.