wandres.dev
PROPERTY WRAPPERS · comportamiento reutilizable

Los wrappers que ya usas: State, Binding, Environment

Qué hacen realmente por dentro los wrappers de SwiftUI: dónde vive el estado si la vista es un struct que se destruye a cada instante, qué guarda un `Binding`, cómo se busca en el entorno, y por qué todo depende del protocolo `DynamicProperty`.

⏱ 18 min

Si has escrito una pantalla de SwiftUI ya has usado property wrappers a docenas, aunque quizá los hayas leído como palabras clave del framework. No lo son: State, Binding, Environment y compañía son tipos corrientes de una biblioteca corriente, declarados con @propertyWrapper igual que los que escribiste tú en las lecciones anteriores. Lo que los vuelve extraordinarios no está en el atributo, sino en el trato que reciben: SwiftUI inspecciona tus vistas, reconoce esos wrappers entre sus propiedades y los conecta a un grafo de almacenamiento que sobrevive a las vistas mismas. Entender esa mecánica convierte un puñado de reglas memorizadas —el estado va privado, el enlace se pasa con dólar— en consecuencias evidentes de una sola idea.

🎯 Al terminar esta lección sabrás
  • Explicar dónde vive de verdad el valor de una propiedad marcada con @State y por qué no en la vista.
  • Describir qué contiene un Binding y por qué transporta escritura sin copiar el dato.
  • Situar @Environment como una búsqueda por el árbol de vistas con seguimiento de dependencias.
  • Identificar DynamicProperty como la pieza que da vida a todos estos wrappers.

State: el estado no cabe en la vista

Una vista de SwiftUI es un struct, es decir, un valor. El framework la construye, le pide su body, la compara con la anterior y la tira; cuando algo cambia, fabrica una instancia nueva desde cero. Con esa política, una propiedad almacenada normal no puede recordar nada: el valor moriría con la instancia, varias veces por segundo.

struct Contador: View {
    @State private var n = 0

    var body: some View {
        Button("Pulsado \(n) veces") { n += 1 }
    }
}

La clave está en lo que State almacena dentro de la vista, que no es el entero. Simplificando su implementación, State guarda una referencia a una caja que pertenece al framework, y el struct de la vista solo lleva ese asa:

// Modelo mental, no la implementacion real
@propertyWrapper
struct MiState<Valor>: DynamicProperty {
    private final class Caja { var valor: Valor; init(_ v: Valor) { valor = v } }
    private let caja: Caja

    init(wrappedValue: Valor) { caja = Caja(wrappedValue) }

    var wrappedValue: Valor {
        get { caja.valor }
        nonmutating set { caja.valor = newValue }
    }

    var projectedValue: Binding<Valor> {
        Binding(get: { caja.valor }, set: { caja.valor = $0 })
    }
}

Dos detalles lo explican casi todo. El primero es el nonmutating set, que ya conoces: escribir en n no muta el struct de la vista, muta la caja, y por eso puedes asignar desde una clausura de botón donde self es una copia inmutable. El segundo es que la caja real no la crea la vista: SwiftUI la mantiene en su propio almacenamiento, indexada por la identidad de la vista en el árbol, y se la entrega a cada instancia nueva antes de pedirle el body. De ahí salen, sin necesidad de memorizarlas, todas las reglas del estado: se declara private porque el valor inicial es solo una semilla y pasarlo desde fuera no reconecta nada; no se lee ni se escribe en el inicializador, porque en ese momento la vista todavía no está instalada en el árbol y no tiene caja asignada; y el valor sobrevive a las recreaciones mientras la identidad se conserve, pero desaparece si el árbol cambia de forma.

Binding: transportar la ubicación

Un Binding no contiene el valor. Contiene cómo llegar hasta él: una clausura de lectura y otra de escritura, exactamente lo que devolvía la proyección del modelo anterior. Ese es su contenido completo, y de ahí se sigue todo su comportamiento.

struct Padre: View {
    @State private var texto = ""

    var body: some View {
        Hijo(campo: $texto)          // el hijo recibe acceso, no una copia
    }
}

struct Hijo: View {
    @Binding var campo: String       // no posee el estado, lo alcanza

    var body: some View {
        TextField("Escribe", text: $campo)   // y lo reenvia hacia abajo
    }
}

Que el hijo pueda seguir pasando $campo hacia abajo se explica con lo que ya sabes: Binding proyecta self. Por eso un enlace se reenvía indefinidamente por el árbol sin degradarse. Y el acceso a subpropiedades —tomar de un Binding a un modelo un Binding a uno de sus campos— viene de que el tipo aplica búsqueda dinámica de miembros y fabrica al vuelo un enlace derivado que lee y escribe a través del original. La consecuencia de diseño es contundente: un Binding marca en la firma de un tipo la diferencia entre tener un dato y tener permiso para cambiarlo en otro sitio, algo que en un lenguaje de semántica de valor no podría expresarse de ninguna otra forma.

Environment: buscar hacia arriba en el árbol

@Environment resuelve un problema distinto: pasar información por muchos niveles sin encadenar parámetros. Cada vista recibe un contenedor de valores de entorno heredado de su padre, que puede sobrescribir para su subárbol.

struct Ficha: View {
    @Environment(\.colorScheme) private var esquema
    @Environment(\.locale) private var idioma

    var body: some View {
        Text(esquema == .dark ? "modo oscuro" : "modo claro")
    }
}

El wrapper guarda un KeyPath hacia el contenedor de valores de entorno y su wrappedValue hace la búsqueda. Pero hay una segunda mitad, invisible y más importante: leer un valor de entorno declara una dependencia. SwiftUI anota que esta vista depende de esa clave y la vuelve a invalidar cuando el valor cambia en algún ancestro. Sin ese registro, el entorno sería un simple diccionario heredado; con él, es un canal reactivo. Y explica el error clásico: leer el entorno fuera de body, por ejemplo en el inicializador de la vista, no solo devuelve un valor sin sentido, sino que no registra dependencia alguna.

DynamicProperty: la pieza que lo une todo

Queda la pregunta que ordena la lección entera: si un property wrapper no es más que un tipo con accesores, ¿de dónde sale la reactividad? No sale del wrapper. Sale de un protocolo casi vacío al que todos ellos conforman:

protocol DynamicProperty {
    mutating func update()
}

Antes de pedir el body de una vista, SwiftUI recorre por reflexión sus propiedades almacenadas, detecta cuáles conforman a DynamicProperty, las enlaza con el almacenamiento persistente que les corresponde según la identidad de la vista y llama a update() en cada una. Solo entonces evalúa el body. Los wrappers no son actores del sistema: son declaraciones legibles por el framework de qué necesita esta vista y de qué depende. La reactividad vive en el motor que las lee.

flowchart TB
arbol[Arbol de vistas de SwiftUI] --> refl[Reflexion sobre las propiedades almacenadas]
refl --> dp[Detecta las que conforman DynamicProperty]
dp --> conecta[Conecta cada una con el almacenamiento persistente]
conecta --> upd[Llama a update en cada una]
upd --> body[Evalua el body de la vista]
body --> cambio[Un cambio invalida la vista y repite el ciclo]
cambio --> arbol
style arbol fill:#cba6f7,color:#11111b
style dp fill:#89b4fa,color:#11111b
style body fill:#a6e3a1,color:#11111b
📦

State · posee

Guarda una referencia a una caja del framework, indexada por la identidad de la vista. La vista no es dueña del valor: solo lo alcanza.

🔗

Binding · alcanza

Guarda una lectura y una escritura hacia un valor ajeno. No copia el dato: transporta el permiso de modificarlo donde vive.

🌳

Environment · busca

Guarda una ruta hacia el contenedor de valores heredado del árbol, y al leerla registra una dependencia que provoca la invalidación.

Merece la pena releer esa tabla como una sola idea: los tres wrappers se distinguen por qué guardan realmente dentro del struct de la vista, y ninguno guarda el dato. Uno guarda una referencia, otro un par de clausuras, el tercero una clave de búsqueda. Esa es la manera de que un valor efímero, recreado decenas de veces por segundo, pueda participar en un sistema con memoria: no llevando el estado encima, sino llevando la forma de encontrarlo.

SwiftUI no es un dialecto, es una biblioteca

Merece la pena detenerse en la magnitud de lo que acabamos de desmontar. SwiftUI parece un lenguaje aparte: tiene atributos que se comportan como palabras clave, cuerpos que se escriben como listas declarativas sin comas ni retornos, firmas que ocultan tipos imposibles de escribir a mano. Y sin embargo el compilador de Swift no conoce SwiftUI. Ni una línea del compilador menciona State, Binding ni View. Todo ese aparente dialecto se sostiene sobre tres mecanismos de propósito general del lenguaje —property wrappers para declarar dependencias de estado, result builders para el DSL del body, tipos opacos con some para devolver jerarquías anónimas de vistas— más la reflexión en tiempo de ejecución que permite al framework leer tus declaraciones. Ese es exactamente el objetivo que se fijó Swift desde SE-0258 en adelante: que las capacidades expresivas necesarias para construir frameworks ambiciosos vivan en el lenguaje como características abiertas, disponibles para cualquiera, en vez de como privilegios cocinados dentro del compilador para un cliente concreto. El resultado tiene una consecuencia práctica que va mucho más allá de la anécdota: cualquiera puede construir su propio @State. Si diseñas un motor de interfaz, un sistema de inyección de dependencias o un gestor de formularios, dispones de las mismas herramientas con las que se construyó SwiftUI, sin pedirle permiso a nadie ni esperar a la siguiente versión del compilador. Y en la dirección contraria, la lección de humildad: cuando @State se comporta de forma que no esperabas, no estás ante magia rota, sino ante un struct con una referencia a una caja y un contrato de identidad que se ha roto en algún punto del árbol.

⚠️
Lo que ha cambiado con Observation

El trío @ObservedObject, @StateObject y @Published, construido enteramente con wrappers sobre Combine, es hoy código heredado. La macro @Observable lo sustituye y lo hace mejor, porque puede reescribir la clase entera y rastrear qué propiedad concreta se leyó. Por qué eso quedaba fuera del alcance de un property wrapper es justo el asunto de la lección siguiente.

⚔️ Abre la caja
  1. Implementa tu propio MiState con una caja de referencia y comprueba que el valor se pierde al recrear el struct que lo contiene, a diferencia del de SwiftUI.
  2. Construye un Binding a mano con Binding(get:set:) sobre una variable local y razona por qué escribir a través de él afecta al original.
  3. Declara una vista con @State no privada, pásale un valor desde el padre y observa qué ocurre cuando el padre se recrea; explica el resultado con la idea de identidad.
  4. Escribe una clave de entorno propia conformando a EnvironmentKey y compruébala en dos ramas distintas del árbol.
  5. Enumera los wrappers de SwiftUI que uses habitualmente y clasifica cada uno según qué almacena: el valor, una referencia a él o una ruta de búsqueda.