El mapa completo: los cinco del estado
@State, @Binding, @Observable, @Environment y @Bindable no son cinco formas de hacer lo mismo: cada uno responde a una pregunta distinta sobre propiedad, alcance y observación. El mapa que elimina la duda al elegir.
Casi todo el sufrimiento con el estado en SwiftUI nace de tratar sus property wrappers como sinónimos intercambiables y elegir por costumbre. No lo son. Cada uno responde a una pregunta diferente y solo a esa: quién es dueño de la memoria, cómo llega el dato hasta la vista que lo necesita, y qué mecanismo detecta que cambió. Cuando separas esas tres preguntas, los cinco dejan de ser un catálogo que memorizar y se convierten en un árbol de decisión de tres ramas donde cada caso tiene exactamente una respuesta correcta.
- Separar las tres dimensiones del sistema: propiedad, transporte y observación.
- Asignar a cada property wrapper el problema exacto que resuelve y ninguno más.
- Distinguir la invalidación por valor de la observación granular de Observation.
- Aplicar un árbol de decisión para elegir sin dudar en cualquier situación.
Tres preguntas, no cinco herramientas
Una vista de SwiftUI es una struct efímera. El sistema la crea, lee su body, produce un árbol de descripción y la tira. Si la vista es desechable, el estado no puede vivir dentro de ella: tiene que vivir en un almacenamiento que SwiftUI mantiene aparte, asociado a la posición e identidad de esa vista en el árbol. Todo el sistema de estado es la respuesta a esa tensión, y se organiza en tres ejes independientes.
La primera pregunta es de propiedad: quién asigna la memoria y decide cuándo se destruye. La segunda es de transporte: cómo viaja la referencia desde el dueño hasta la vista que la consume, explícitamente por el inicializador o implícitamente por el árbol. La tercera es de observación: qué mecanismo entera a SwiftUI de que el dato cambió para que reconstruya el body correcto.
Confundir los ejes es la causa raíz de casi todos los bugs de esta categoría. Un dato que se declara con @State en dos vistas distintas no es un dato compartido: son dos almacenamientos independientes que casualmente empezaron con el mismo valor. Un objeto que se pasa por el inicializador sin @Bindable sigue siendo observable, pero no ofrece bindings. Cada síntoma raro se explica mirando en qué eje te equivocaste.
flowchart TD A[Quien posee el dato] --> B[Nace y muere con la vista] A --> C[Vive fuera de la vista] B --> D[State] C --> E[Llega explicito por el init] C --> F[Lo comparte todo el subarbol] E --> G[Binding para valores] E --> H[Bindable para objetos observables] F --> I[Environment]
Cada wrapper y su problema exacto
@State: propiedad local
Declara que esta vista es dueña del valor. SwiftUI reserva almacenamiento persistente fuera de la struct y lo asocia a la identidad de la vista. El inicializador se evalúa una sola vez, no en cada body.
@Binding: acceso prestado
No almacena nada. Es un par de funciones de lectura y escritura hacia un estado ajeno. Permite escribir en la fuente de la verdad sin poseerla ni duplicarla.
@Observable: observación granular
Macro sobre una clase de referencia. Convierte cada propiedad almacenada en una computada que registra lecturas y anuncia mutaciones a un registrar interno.
@Environment: transporte implícito
Inyecta por el árbol sin pasar por cada inicializador intermedio. Sirve para valores del sistema por clave y para objetos observables por tipo.
@Bindable: bindings a un objeto
Toma un objeto @Observable que ya tienes y fabrica bindings a sus propiedades. No implica propiedad ni observación extra: solo proyección con el símbolo dólar.
La tabla condensa la elección en las tres dimensiones que importan:
| Wrapper | Posee | Transporte | Observa |
|---|---|---|---|
@State |
Sí | Local | Por valor o por registrar |
@Binding |
No | Explícito | Hereda del origen |
@Observable |
No aplica | No aplica | Por propiedad leída |
@Environment |
No | Implícito | Por clave o por tipo |
@Bindable |
No | Explícito | Hereda del objeto |
Dos filas piden una nota. @Environment tiene en realidad dos caras que comparten nombre y mecanismo pero no propósito: la de clave, para valores que el sistema define y propaga por el árbol, y la de tipo, para objetos observables que tú inyectaste más arriba. La primera es lectura de contexto; la segunda es inyección de dependencias, y conviene no mezclarlas mentalmente aunque la sintaxis las emparente.
@Environment(\.colorScheme) private var esquema // por clave: lo define el sistema
@Environment(\.dismiss) private var cerrar // por clave: una accion, no un dato
@Environment(Sesion.self) private var sesion // por tipo: objeto que tu inyectaste
Y @Observable no es un property wrapper sino una macro sobre el tipo: no decora una propiedad de la vista, transforma la clase entera. Que aparezca en la misma lista es una comodidad expositiva, porque responde a la tercera pregunta del sistema, no porque se use en el mismo sitio que los otros cuatro.
Los dos motores de invalidación
Aquí está el matiz que separa a quien usa SwiftUI de quien lo entiende: no hay un mecanismo de detección de cambios, hay dos, y conviven.
El primero es el de @State con tipos de valor. Cuando escribes en el almacenamiento, SwiftUI marca la vista como inválida y vuelve a evaluar su body. La granularidad es la vista entera: si la struct de estado tiene ocho campos y cambias uno, la vista se recalcula completa. El sistema mitiga el coste comparando el árbol resultante antes de tocar el render real.
El segundo es el de Observation. La macro @Observable reescribe cada propiedad almacenada en una computada cuyo get llama a access y cuyo set envuelve la escritura en withMutation sobre un ObservationRegistrar. Mientras SwiftUI evalúa un body, mantiene abierta una transacción de seguimiento: cualquier propiedad leída durante esa evaluación queda anotada como dependencia de esa vista concreta.
@Observable
final class Sesion {
var usuario: String = "invitado"
var notificaciones: Int = 0
var ultimoAcceso: Date = .now
}
struct BadgeView: View {
let sesion: Sesion // sin wrapper: basta para observar
var body: some View {
// solo lee `notificaciones`: cambiar `usuario` NO invalida esta vista
Text("\(sesion.notificaciones)")
}
}
La consecuencia práctica es enorme y contraintuitiva: un objeto observable no necesita ningún property wrapper para ser observado. Basta con que la vista lea una de sus propiedades dentro de body. El wrapper solo aparece cuando además quieres poseerlo, con @State, o derivar bindings de él, con @Bindable.
Detente en la diferencia arquitectónica. En el modelo antiguo, con ObservableObject y @Published, la vista se suscribía a un publisher del objeto entero: cualquier mutación de cualquier propiedad emitía y redibujaba a todos los suscriptores. La dependencia era declarada por el programador al elegir dónde poner @ObservedObject, y su granularidad era el objeto. Observation invierte el contrato: la dependencia se descubre en tiempo de ejecución observando qué propiedades lee de verdad cada body durante su evaluación. Es exactamente la misma idea que sostiene la reactividad de grano fino de los signals modernos, y tiene tres consecuencias que cambian cómo diseñas. Primero, el modelo puede crecer sin castigo: añadir veinte propiedades a una clase no encarece las vistas que solo leen dos. Segundo, la granularidad ya no la decide la forma del modelo sino la forma de la lectura, así que dividir un modelo grande en varios pequeños deja de ser una optimización obligatoria y pasa a ser una decisión de diseño. Tercero, y menos evidente, una propiedad leída fuera de un body no queda registrada: si calculas algo en init o en un Task de fondo, esa lectura no crea dependencia y la vista no reaccionará. El sistema es más listo, pero también más literal, y en la lección sobre por qué a veces no se actualiza verás que casi todos los fallos son lecturas que nunca ocurrieron donde el sistema estaba mirando.
Que la vista posea un objeto de referencia se escribe @State private var modelo = Modelo(). Aquí @State no aporta observación, que ya la da @Observable: aporta ciclo de vida, garantizando que el objeto se crea una vez y sobrevive a las reevaluaciones del body en lugar de fabricarse de nuevo en cada una.
El árbol de decisión aplicado
Un formulario recoge los tres casos en veinte líneas. La vista raíz posee el modelo, la subvista de edición recibe bindings, y el tema visual viaja por el entorno sin aparecer en ningún inicializador intermedio.
struct PerfilRaiz: View {
@State private var perfil = Perfil() // posee: nace aqui
@Environment(\.colorScheme) private var tema // valor del sistema, implicito
var body: some View {
EditorPerfil(perfil: perfil) // pasa la referencia, sin copiar
.preferredColorScheme(tema)
}
}
struct EditorPerfil: View {
@Bindable var perfil: Perfil // no posee: solo deriva bindings
var body: some View {
Form {
TextField("Nombre", text: $perfil.nombre)
Toggle("Publico", isOn: $perfil.esPublico)
}
}
}
Ninguna elección es estilística. @State en la raíz porque el perfil nace y muere con esa pantalla. @Bindable en el editor porque necesita escribir en propiedades ajenas y solo eso. @Environment para el tema porque atraviesa niveles que no tienen nada que decir sobre él. Las tres preguntas, tres respuestas, cero ambigüedad.
- Enumera cinco datos de una app que uses y clasifica cada uno en los tres ejes: propiedad, transporte y observación.
- Escribe una clase
@Observablecon tres propiedades y dos vistas que lean solo una cada una; razona cuál se invalida al mutar la tercera. - Convierte una vista que recibe
@Binding var texto: Stringen otra que reciba el objeto con@Bindabley explica qué ganaste y qué perdiste. - Justifica por qué
@State private var modelo = Modelo()no duplica la observación que ya da la macro. - Encuentra en tu código un
@Stateque debería ser@Bindingy describe el bug latente que escondía.