wandres.dev
ARQUITECTURA DE APPS · patrones y capas

MVVM con `@Observable`: el modelo de vista moderno

La macro `@Observable` no es una versión abreviada del viejo protocolo de objetos observables: cambia el grano de la invalidación, elimina los envoltorios de propiedad del modelo y convierte el modelo de vista en una clase Swift ordinaria. Esta lección examina qué gana esa forma frente al estado suelto en la vista, cómo se conecta con el ciclo de vida de SwiftUI y en qué tres frentes concretos se queda corta.

⏱ 19 min

Durante años, escribir un modelo de vista en SwiftUI costaba un impuesto visible: conformar un protocolo, decorar cada propiedad publicada, elegir entre tres envoltorios distintos en el punto de uso y aceptar que cualquier cambio invalidaba la vista entera aunque el campo modificado no apareciera en pantalla. La macro @Observable retiró ese impuesto de golpe. Lo interesante no es la comodidad, que se agota en la primera semana, sino lo que la nueva forma revela: cuando el coste sintáctico de una capa cae casi a cero, el debate sobre si conviene tenerla deja de estar contaminado por la molestia de escribirla, y por fin se puede discutir lo único que importaba, que es qué responsabilidad concreta le estás dando y qué sigue sin poder resolver.

🎯 Al terminar esta lección sabrás
  • Entender el seguimiento por propiedad de @Observable y por qué cambia el grano de la invalidación.
  • Escribir un modelo de vista moderno con propiedades derivadas, mutación explícita y trabajo asíncrono acotado.
  • Comparar esa forma con el estado suelto repartido en la vista y nombrar qué gana exactamente.
  • Reconocer los tres límites reales del patrón: composición, efectos y verificación exhaustiva.

Qué cambió realmente la macro

La macro reescribe la clase en tiempo de compilación para que cada acceso de lectura se registre y cada escritura notifique. El efecto práctico es que la unidad de observación deja de ser el objeto y pasa a ser la propiedad leída dentro del body. Una vista que solo lee titulo no se invalida cuando cambia contador, y eso no es una optimización marginal: es la diferencia entre una pantalla que redibuja de más y otra que no.

@Observable
final class EditorViewModel {
    var titulo = ""
    var cuerpo = ""
    private(set) var guardando = false
    private(set) var error: String?

    var puedeGuardar: Bool { !titulo.isEmpty && cuerpo.count >= 10 }
    var contadorRestante: Int { max(0, 280 - cuerpo.count) }
}

struct TituloView: View {
    let modelo: EditorViewModel
    var body: some View {
        // Solo lee titulo: cambiar cuerpo no invalida esta vista
        Text(modelo.titulo)
    }
}

Fíjate en un detalle de forma que tiene consecuencias de fondo: el modelo se pasa como constante, sin envoltorio de propiedad. Solo se usa @State en el punto donde nace el objeto, para atarlo al ciclo de vida de esa vista; quien lo recibe ya solo necesita la referencia. Esa asimetría entre creación y consumo es la que confunde a quien llega del protocolo antiguo, donde el punto de uso siempre llevaba decorador.

El modelo de vista moderno

Un modelo de vista que se gana el sitio tiene tres rasgos: expone estado derivado en lugar de obligar a la vista a calcularlo, protege las transiciones detrás de métodos con nombre y encapsula el trabajo asíncrono con su propio estado de progreso y error.

@Observable
final class EditorViewModel {
    private let repositorio: NotasRepository

    var titulo = ""
    var cuerpo = ""
    private(set) var guardando = false
    private(set) var error: String?

    init(repositorio: NotasRepository) { self.repositorio = repositorio }

    @MainActor
    func guardar() async {
        guard puedeGuardar, !guardando else { return }
        guardando = true
        defer { guardando = false }
        do { try await repositorio.guardar(titulo: titulo, cuerpo: cuerpo) }
        catch { self.error = error.localizedDescription }
    }
}

struct EditorView: View {
    @State private var modelo: EditorViewModel
    var body: some View {
        Form {
            TextField("Titulo", text: $modelo.titulo)
            Button("Guardar") { Task { await modelo.guardar() } }
                .disabled(!modelo.puedeGuardar || modelo.guardando)
        }
    }
}

El private(set) es la pieza silenciosa: declara que la vista puede leer si algo está en curso pero no puede fingir que terminó. Cada propiedad que dejas escribible desde fuera es una transición que has decidido no controlar.

🎯

Invalidación por propiedad

Solo se recalculan las vistas que leyeron el campo que cambió, no todas las que tocaron el objeto.

🧾

Estado derivado con nombre

puedeGuardar documenta la regla una vez. La vista pregunta en lugar de recalcular en cada body.

🔒

Transiciones cerradas

Métodos públicos y campos de solo lectura hacia fuera. La vista pide, no manipula.

💉

Dependencias explícitas

El repositorio entra por el inicializador, así que la prueba sustituye la red sin tocar la vista.

Qué gana frente al estado suelto

El estado suelto, repartido en varias propiedades marcadas con @State dentro de la vista, funciona perfectamente hasta que las reglas se cruzan. El punto de quiebre no es la cantidad de campos sino la aparición de invariantes entre ellos.

Situación Estado suelto en la vista Modelo de vista con @Observable
Tres campos independientes ideal: cero ceremonia capa innecesaria
Regla que cruza dos campos se repite en cada body que la necesita vive una vez como propiedad derivada
Carga asíncrona con error se enreda con el ciclo de vida de la vista queda acotada en un método con su estado
Prueba de la regla exige renderizar o duplicar la lógica se instancia la clase y se afirma
Reutilizar la pantalla en otro sitio hay que copiar el bloque de estado se pasa el mismo tipo con otra dependencia
flowchart TD
A[Regla que cruza varios campos] --> B{vive dentro del body}
B -->|si| C[Se repite y no se puede probar sola]
B -->|no| D[Propiedad derivada del modelo de vista]
D --> E[Una definicion y una prueba sin render]
style C fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b

Dónde se queda corto

Conviene ser preciso sobre los límites, porque el entusiasmo por la macro suele saltárselos y porque cada uno de ellos anticipa una decisión de las lecciones siguientes. Primero, la composición: dos modelos de vista que deben coordinarse no tienen ningún mecanismo estándar para hacerlo, así que aparecen las referencias cruzadas, los cierres de callback y el hijo que guarda un puntero al padre. Segundo, los efectos: nada impide lanzar una tarea que sobreviva a la pantalla, ni existe una cancelación gobernada por el patrón. Tercero, la verificación: puedes probar que un método deja el estado correcto, pero no que ese método sea el único camino posible ni que hayas cubierto todas las transiciones, porque el conjunto de mutaciones legales no está representado en ningún tipo.

// El limite de composicion, visto de cerca
@Observable final class FiltroViewModel {
    var texto = ""
    var onCambio: ((String) -> Void)?   // el padre se entera por callback
}

@Observable final class ListaViewModel {
    let filtro = FiltroViewModel()
    private(set) var resultados: [Nota] = []
    init() { filtro.onCambio = { [weak self] t in self?.buscar(t) } }
    private func buscar(_ t: String) { /* ... */ }
}

Ese cierre es el síntoma. Funciona, se lee bien y no tiene alternativa dentro del patrón, pero establece una relación entre dos objetos que el sistema de tipos no describe: nadie puede saber, leyendo FiltroViewModel, quién reacciona a sus cambios ni en qué orden. Cuando esa red de cierres pasa de tres nodos, la app tiene un grafo de comunicación que solo existe en la memoria de quien lo escribió.

⚠️
El modelo de vista que se convierte en controlador

El fallo más común no es tener modelo de vista: es dejar que crezca hasta ser el antiguo controlador con otro nombre. Cuando una de estas clases pasa de trescientas líneas, acumula media docena de dependencias o coordina navegación además de estado, ya no es una capa de presentación sino un depósito. La señal temprana es la aparición de propiedades que no lee ninguna vista.

Facilitar una capa no es lo mismo que justificarla

Hay una ilusión persistente en la ingeniería: creer que cuando una herramienta abarata un gesto, ese gesto se vuelve correcto. La macro @Observable abarató radicalmente el modelo de vista, y con ello desplazó el debate sin resolverlo. Antes se discutía sobre la molestia de escribirlo, que era un argumento pobre pero al menos actuaba de freno; ahora que el freno desapareció, la única pregunta que queda en pie es la que siempre importó y casi nadie hacía: qué invariante del sistema custodia esta clase, y qué comportamiento se volvería posible si no existiera. Un modelo de vista legítimo tiene una respuesta breve y verificable a esa pregunta, algo como que impide enviar un formulario incompleto o que garantiza que no haya dos guardados simultáneos. Un modelo de vista ilegítimo responde con una descripción de sí mismo, algo como que agrupa el estado de la pantalla, que es tanto como no responder. La distinción tiene además una consecuencia sobre los límites de la lección: los tres frentes donde este patrón se queda corto no son defectos de la macro, son el precio de una arquitectura donde la mutación es un método y no un dato. Mientras un cambio de estado sea una llamada, no hay manera de enumerar los cambios posibles, ni de grabarlos, ni de reproducirlos, ni de probar exhaustivamente que ninguno deja el sistema en un estado imposible. Ese techo es real y no se sube con más disciplina: se sube cambiando de arquitectura, que es exactamente la decisión de la lección siguiente.

⚔️ Convierte estado suelto en invariantes con nombre
  1. Busca en tu app una vista con cuatro o más propiedades @State y escribe la lista de reglas que las relacionan entre sí.
  2. Extrae esas reglas a un tipo @Observable como propiedades derivadas, y deja los campos mutables con private(set) siempre que la vista solo deba leerlos.
  3. Escribe pruebas de las reglas instanciando la clase directamente, sin renderizar ninguna vista. Anota cuántas te habría costado antes.
  4. Mueve la carga asíncrona a un método marcado con @MainActor que gestione su propio indicador de progreso y su error.
  5. Comprueba el tercer límite en la práctica: intenta escribir una prueba que demuestre que no existe ninguna secuencia de llamadas capaz de dejar el modelo en un estado inconsistente, y explica por qué no puedes.