Cuándo NO se actualiza: los tres motivos y su diagnóstico
La vista no refleja el cambio y el dato es correcto. Los tres únicos motivos por los que SwiftUI no reevalúa un body, cómo distinguirlos en segundos y el árbol de diagnóstico que va del síntoma a la causa.
El dato cambió, lo has impreso en consola, el valor es correcto, y la pantalla sigue mostrando lo de antes. Es el bug más frustrante de SwiftUI porque no hay error, no hay crash y no hay traza: simplemente no pasa nada. La buena noticia es que la causa no es misteriosa ni aleatoria. Descontando lo exótico, el fallo pertenece siempre a una de tres familias, y cada una tiene una firma reconocible y una comprobación de treinta segundos. Aprender a clasificar el síntoma antes de tocar el código convierte una tarde de prueba y error en un diagnóstico dirigido.
- Reconocer los tres motivos por los que un
bodyno se reevalúa. - Detectar dependencias que nunca se registraron por leerse fuera del
body. - Distinguir una escritura sobre una copia de una escritura sobre la fuente.
- Aplicar un árbol de diagnóstico que va del síntoma al motivo sin adivinar.
Motivo 1: la dependencia nunca se registró
SwiftUI reevalúa un body cuando cambia algo de lo que ese body depende, y la dependencia solo nace si la lectura ocurre durante la evaluación del body, dentro de la transacción de seguimiento que Observation mantiene abierta. Una lectura fuera de esa ventana no cuenta. Es el motivo más frecuente y el más invisible, porque el código parece correcto y el dato es el bueno.
struct PanelMal: View {
let total: Int // valor copiado en la construccion
init(sesion: Sesion) {
self.total = sesion.notificaciones // lectura FUERA del body: no registra
}
var body: some View { Text("\(total)") }
}
struct PanelBien: View {
let sesion: Sesion // guardamos la referencia, no el valor
var body: some View {
Text("\(sesion.notificaciones)") // lectura DENTRO del body: registra
}
}
La variante más sutil aparece cuando la lectura ocurre dentro de un cierre que se ejecuta después. Un valor leído en la acción de un botón, en un onAppear o en un Task no crea dependencia alguna: se ejecutan fuera de la evaluación del body, cuando la transacción de seguimiento ya se cerró.
Las dos causas hermanas completan la familia. Una clase mutable sin la macro @Observable no tiene registrar y sus mutaciones son invisibles por completo. Y una propiedad marcada con @ObservationIgnored se excluye deliberadamente del seguimiento, algo legítimo para cachés internas y desastroso si la lee la interfaz.
Pon un print de la propiedad dentro del body, no en el cierre ni en el inicializador. Si el print no aparece cuando el dato cambia, el body no se está reevaluando y estás en el motivo 1 o en el 3. Si aparece con el valor viejo, estás en el motivo 2.
Motivo 2: escribiste sobre una copia
El segundo motivo es un accidente de semántica de valor. Los tipos de valor de Swift se copian al asignarse y al pasarse, así que es fácil acabar mutando un duplicado mientras la fuente de la verdad, la que la vista lee, permanece intacta.
El caso canónico es inicializar un @State a partir de un parámetro. Ese inicializador se evalúa una sola vez por identidad, así que el @State toma una foto del valor y a partir de ahí lleva vida propia. Cuando la fuente cambia, la copia no se entera.
struct FichaMal: View {
@State private var nombre: String
init(usuario: Usuario) {
_nombre = State(initialValue: usuario.nombre) // foto unica, no se resincroniza
}
var body: some View { TextField("Nombre", text: $nombre) }
}
struct FichaBien: View {
@Bindable var usuario: Usuario // escribe en la fuente real
var body: some View { TextField("Nombre", text: $usuario.nombre) }
}
La segunda forma de este motivo es guardar una struct observable dentro de una clase, o extraer un elemento de una colección a una variable local, mutarlo y no devolverlo. La mutación es real, ocurre, y se pierde en un valor temporal que nadie observa.
// La mutacion muere en la copia local
var tarea = modelo.tareas[indice]
tarea.hecha = true // toca la copia
// falta: modelo.tareas[indice] = tarea
Aquí el print dentro del body sí se dispara, pero muestra el valor viejo. Esa firma distingue el motivo 2 de los otros dos con total fiabilidad.
Existe una variante inversa que produce el síntoma contrario y despista mucho: el estado sí se actualiza, pero vuelve solo a su valor anterior. Suele ser una copia que se resincroniza en cada evaluación, típicamente un onChange o un task que reescribe el @State desde la fuente justo después de que el usuario lo modifique. La solución nunca es añadir otra sincronización: es eliminar la copia y dejar una sola fuente de verdad.
// Sintoma: escribes y el campo revierte solo
.onChange(of: usuario.nombre) { _, nuevo in
nombre = nuevo // pisa lo que el usuario acaba de teclear
}
Motivo 3: la mutación ocurrió donde nadie miraba
El tercer motivo agrupa los casos en los que la escritura existe, toca la fuente correcta y aun así no propaga, porque ocurrió fuera del contexto donde SwiftUI la puede procesar.
La causa dominante es la concurrencia. El estado de la interfaz vive en el actor principal, y una mutación desde un contexto de fondo no llega a la transacción de dibujo. Con el aislamiento por defecto de las versiones modernas de Swift esto casi siempre lo atrapa el compilador, pero sigue apareciendo en código que cruza puentes con Combine, delegados de frameworks antiguos o cierres de callbacks de C.
@Observable @MainActor
final class Feed {
var articulos: [Articulo] = []
func recargar() async {
let nuevos = await api.articulos() // trabajo fuera del hilo principal
articulos = nuevos // escritura de vuelta en MainActor
}
}
La segunda causa es reemplazar el objeto entero cuando la vista dependía de sus propiedades, o al revés: una vista que solo lee modelo.nombre no reacciona si sustituyes la instancia por otra sin que nadie observe la referencia. Y la tercera, más rara pero letal, es mutar estado durante la evaluación del body, lo que produce el aviso de modificar estado durante una actualización y un comportamiento indefinido: el sistema no puede reaccionar a un cambio que ocurre mientras está calculando la reacción anterior.
Cuando llevas un tiempo diagnosticando esto aparece la simetría de fondo. SwiftUI mantiene una invariante muy concreta: la pantalla es una función pura del estado, evaluada en el actor principal, sobre dependencias descubiertas durante la evaluación. Los tres motivos son las tres únicas maneras de romperla, una por cada cláusula de la frase. El motivo 1 rompe el descubrimiento de dependencias: la lectura ocurrió fuera de la ventana en que el sistema estaba anotando, así que la arista del grafo nunca existió y no hay camino por el que propagar la invalidación. El motivo 2 rompe la unicidad de la fuente: hay dos copias del dato donde debía haber una, la escritura fue a la que nadie lee y el grafo, intacto, propaga fielmente un valor que ya no representa la verdad. El motivo 3 rompe el contexto de evaluación: la escritura existe y toca la fuente correcta, pero ocurre en un hilo o en un instante en que el sistema no puede integrarla en la transacción de dibujo. Verlo así tiene un valor operativo inmediato, porque convierte la pregunta abierta por qué no se actualiza en tres preguntas cerradas que se responden por observación: ¿existe la arista, es única la fuente, es válido el contexto? Y tiene un valor de diseño aún mayor: casi todo el trabajo de arquitectura de estado consiste en hacer imposible romper alguna de las tres, con una única fuente de verdad por dato, lecturas siempre dentro del body y aislamiento en el actor principal declarado en el tipo y no recordado por disciplina.
El árbol de diagnóstico
El procedimiento es mecánico y no requiere hipótesis previas. Instrumenta primero, deduce después.
flowchart TD A[La vista no refleja el cambio] --> B[Pon un print dentro del body] B --> C[No imprime nada] B --> D[Imprime con el valor viejo] B --> E[Imprime con el valor nuevo] C --> F[Es la clase Observable y se lee en el body] F -->|No| G[Motivo 1 dependencia no registrada] F -->|Si| H[Motivo 3 revisa MainActor y el contexto] D --> I[Motivo 2 hay una copia entre medias] E --> J[El estado va bien: revisa identidad o layout]
Conviene aplicarlo en ese orden y no saltarse el primer paso. La tentación es empezar cambiando cosas —añadir un wrapper, mover una declaración, envolver en withAnimation— y esa estrategia resuelve por casualidad uno de cada cinco casos mientras deja el modelo mental intacto para el siguiente. El print dentro del body cuesta diez segundos y particiona el espacio de causas en tres regiones disjuntas antes de tocar una línea.
La rama derecha merece una nota. Si el body se reevalúa y el valor es el correcto pero la pantalla no cambia, el problema ya no es de estado sino de identidad o de presentación: una vista que perdió su identidad y volvió a su valor inicial, un contenedor que la recorta, o una animación que oculta el resultado. Ese diagnóstico devuelve la pelota a la lección de identidad.
Dos herramientas aceleran el proceso. La llamada a Self._printChanges dentro del body imprime qué dependencia concreta provocó la reevaluación, lo que resuelve el motivo 1 de un vistazo. Y el inspector de jerarquía de vistas de Xcode confirma si la vista sigue en el árbol o si desapareció con su estado, que es la confusión más común entre el motivo 2 y un problema de identidad.
- Escribe una vista que copie una propiedad observable en su
inity comprueba que no se actualiza; arréglala moviendo la lectura albody. - Inicializa un
@Statedesde un parámetro, cambia el parámetro desde fuera y explica por qué la vista muestra el valor antiguo. - Provoca una mutación desde un contexto de fondo y observa qué te dice el compilador antes de que llegue a ejecutarse.
- Añade
Self._printChangesa unbodyde tu app y documenta qué dependencia dispara cada reevaluación. - Toma un bug real que hayas tenido y clasifícalo en el árbol; escribe en una frase qué cláusula de la invariante se rompió.