La identidad de la vista: qué considera SwiftUI la misma vista
El estado no se guarda en la vista sino en su identidad. Identidad estructural frente a identidad explícita, el papel de id() y ForEach, y la regla exacta que decide si tu estado sobrevive o se destruye.
Hay una pregunta que SwiftUI se hace en cada actualización y que casi nadie formula en voz alta: esta vista que acabo de calcular, ¿es la misma que tenía antes o es otra distinta? De esa respuesta cuelga todo lo demás. Si es la misma, su @State sobrevive, sus animaciones continúan y sus gestos siguen activos. Si es otra, el almacenamiento anterior se destruye, el nuevo arranca con su valor inicial y aparece una transición. La identidad no es un detalle de implementación: es el concepto sobre el que descansa el sistema de estado entero, y comprenderla convierte una lista de comportamientos misteriosos en un solo principio.
- Entender por qué el estado se asocia a la identidad y no a la instancia de la
struct. - Distinguir identidad estructural de identidad explícita y saber cuál manda.
- Predecir cuándo un cambio de rama o de
iddestruye el estado acumulado. - Usar el modificador
idcomo herramienta deliberada de reinicio.
La posición en el árbol es el nombre
Un valor @State no vive dentro de la struct de la vista, porque esa struct se crea y se destruye decenas de veces por segundo. Vive en un almacenamiento que SwiftUI gestiona aparte y que indexa por la identidad de la vista. La primera fuente de identidad es la posición: el camino desde la raíz del árbol hasta ese nodo, junto con el tipo de la vista.
A eso se le llama identidad estructural. Dos vistas del mismo tipo en la misma posición a través de dos actualizaciones consecutivas son, para SwiftUI, la misma vista, y comparten almacenamiento. Cambia el tipo o cambia el camino, y pasan a ser vistas distintas aunque en pantalla se vean idénticas.
struct Contador: View {
@State private var n = 0 // vive en la identidad, no en la struct
var body: some View {
Button("Pulsado \(n) veces") { n += 1 }
}
}
El detalle crucial es que la identidad estructural la fija la forma del código, no los datos. Un if y un else producen dos posiciones diferentes del árbol, aunque contengan la misma vista escrita con las mismas letras.
// DOS identidades distintas: cada rama es su propia posicion
if compacto {
Contador() // identidad A
} else {
Contador() // identidad B, estado independiente
}
// UNA sola identidad: la misma posicion con parametros distintos
Contador()
.frame(width: compacto ? 120 : 240)
En el primer caso, alternar el Bool destruye el contador de una rama y crea el de la otra desde cero: el número vuelve a cero. En el segundo, la vista es siempre la misma y solo cambia su medida, así que el número se conserva. Ese contraste es la lección entera en dos ejemplos.
flowchart TD R[Raiz] --> C[Condicional] C -->|rama verdadera| A[Contador identidad A] C -->|rama falsa| B[Contador identidad B] A -.-> S1[Almacenamiento propio] B -.-> S2[Almacenamiento propio] R --> M[Contador unico con frame variable] M -.-> S3[Almacenamiento que sobrevive]
La identidad explícita: cuando tú pones el nombre
La segunda fuente es la explícita: tú declaras qué distingue a una vista de otra. Aparece en dos sitios, y son el mismo mecanismo con distinta cara.
El primero es ForEach, donde el id de cada elemento nombra a la vista correspondiente. Si el identificador de una fila cambia, SwiftUI concluye que esa fila es otra, destruye su estado y anima su entrada. Ese es el motivo real por el que usar el índice del array como identificador rompe listas editables: al insertar en la cabeza, todas las posiciones se renumeran y el sistema cree que todas las filas fueron reemplazadas.
// FRAGIL: el indice cambia al insertar, todas las filas cambian de identidad
ForEach(Array(tareas.enumerated()), id: \.offset) { _, tarea in
FilaTarea(tarea: tarea)
}
// CORRECTO: la identidad viaja con el dato, no con su posicion
ForEach(tareas) { tarea in // Tarea conforma Identifiable
FilaTarea(tarea: tarea)
}
El segundo es el modificador id, que asigna un nombre explícito a cualquier vista. Cuando el valor que le pasas cambia, la vista anterior se destruye por completo con su estado y nace una nueva.
DetalleUsuario(usuario: usuario)
.id(usuario.id) // cambiar de usuario reinicia todo el estado interno
La identidad explícita gana sobre la estructural. Aunque la posición en el árbol sea exactamente la misma, un id distinto significa una vista distinta. Ese es todo el contrato.
Merece la pena ver cuánto se explica desde aquí. La documentación presenta el estado, las animaciones, las transiciones y el rendimiento de las listas como temas separados, pero los cuatro son consecuencias de una única decisión de diseño: SwiftUI no diffea árboles genéricos, empareja identidades. Cuando llega una actualización, el sistema recorre el árbol nuevo y, para cada nodo, busca en el anterior un nodo con la misma identidad. Si lo encuentra, es una actualización: reutiliza el almacenamiento de estado, interpola las propiedades animables entre el valor viejo y el nuevo y mantiene vivos gestos y temporizadores. Si no lo encuentra, hay una inserción, y el nodo huérfano del árbol viejo es una eliminación; ahí es donde se aplican las transiciones y donde el estado se libera. Toda la fenomenología que uno atribuye a reglas independientes cae de este único algoritmo. Por eso una animación no arranca cuando cambias de rama en un if: no hay animación posible entre dos vistas que el sistema considera seres distintos, solo una desaparición y una aparición. Por eso una lista con identificadores inestables no solo pierde el estado de sus filas sino que además destruye su rendimiento, porque cada actualización se traduce en reciclar la tabla entera en lugar de mover cuatro filas. Y por eso AnyView sale caro: al borrar el tipo estático borra la información con la que el sistema construía la identidad estructural, y lo obliga a asumir lo peor. Interioriza la pregunta ¿qué identidad tiene esto? y dejarás de depurar síntomas para depurar causas.
Qué sobrevive y qué se pierde
Con el principio en la mano, la casuística se vuelve predecible. Se conserva el estado cuando la identidad se mantiene: cambian los parámetros de la vista, cambian sus modificadores, cambia el contenido que muestra, se reordena un ForEach con identificadores estables. Se pierde el estado cuando la identidad cambia: alternas ramas de un condicional, cambias el valor del modificador id, cambia el identificador de un elemento en un ForEach, o la vista sale de pantalla en un NavigationStack y se retira del árbol.
Hay un caso intermedio que confunde a mucha gente. Envolver una vista en un contenedor perezoso como LazyVStack no cambia su identidad, pero sí su ciclo de vida: las filas fuera de la ventana visible pueden retirarse del árbol y, al volver, arrancan de cero. No es una excepción a la regla, es la regla aplicada a un árbol que se poda.
struct FilaExpandible: View {
@State private var abierta = false // se pierde si la fila sale del arbol
var body: some View {
DisclosureGroup("Detalle", isExpanded: $abierta) { Contenido() }
}
}
La lectura arquitectónica es directa: si un dato debe sobrevivir a la desaparición de la vista, ese dato no pertenece a la vista. Pertenece al modelo, y esa conclusión es exactamente la que desarrolla la lección de elevar el estado.
Poner id sobre una vista cara y alimentarlo con un valor que cambia a menudo equivale a reconstruirla entera en cada cambio, con su estado perdido y su transición correspondiente. Úsalo cuando el reinicio sea justo lo que quieres, no como parche para forzar una actualización que no llega.
El reinicio deliberado
Bien usado, el modificador id resuelve limpiamente un problema real: reiniciar un subárbol completo cuando su sujeto cambia. Un editor con borrador local, un formulario de varias páginas o una vista de detalle que arrastra scroll y foco quedan en un estado inconsistente si el dato subyacente cambia bajo sus pies. Declarar la identidad correcta lo resuelve sin una sola línea de limpieza manual.
struct EditorNota: View {
let nota: Nota
@State private var borrador: String
init(nota: Nota) {
self.nota = nota
_borrador = State(initialValue: nota.texto) // se ejecuta una vez por identidad
}
var body: some View {
TextEditor(text: $borrador)
}
}
// El init solo se reejecuta si la identidad cambia: se la damos explicita
EditorNota(nota: notaActual)
.id(notaActual.id)
Sin el id, cambiar de nota reutilizaría la misma identidad, el inicializador no volvería a correr y el borrador seguiría mostrando el texto de la nota anterior. Ese bug, tan común como desconcertante, deja de serlo cuando lo lees como lo que es: le pediste al sistema que tratara dos notas distintas como la misma vista.
- Escribe un contador dentro de un
ify otro fuera con un modificador variable; predice qué pasa al alternar y comprueba. - Construye un
ForEachconidpor índice, inserta un elemento al principio y observa qué estado se pierde. - Aplica
.id(valor)sobre una vista con@Statey describe qué ocurre exactamente en el instante del cambio. - Explica en dos frases por qué una transición no se dispara al cambiar parámetros y sí al cambiar identidad.
- Localiza un dato de tu app que se pierda al navegar y decide si debe subir al modelo o proteger su identidad.