SwiftUI con @Observable: el nuevo modelo de datos en Swift 5.9 y iOS 17

Hasta iOS 17, el modelo de datos de SwiftUI giraba en torno a ObservableObject, @StateObject, @ObservedObject y @Published. Funcionaba, pero tenía un coste: cualquier cambio en cualquier propiedad @Published de un objeto reconstruía todas las vistas que lo observaban, independientemente de si usaban esa propiedad concreta. Swift 5.9 introduce el framework Observation y la macro @Observable, que resuelve este problema de raíz con un mecanismo de seguimiento por acceso.

De ObservableObject a @Observable

La diferencia en la sintaxis es inmediata:

// Antes (iOS 13+)
class TareasViewModel: ObservableObject {
    @Published var tareas: [Tarea] = []
    @Published var filtro: FiltroTarea = .todas
    @Published var cargando = false
}

// Ahora (iOS 17+, Swift 5.9)
@Observable
class TareasViewModel {
    var tareas: [Tarea] = []
    var filtro: FiltroTarea = .todas
    var cargando = false
}

Con @Observable desaparecen @Published y la necesidad de heredar de ObservableObject. La macro genera el código de seguimiento automáticamente para todas las propiedades almacenadas.

Seguimiento por acceso, no por cambio de objeto

El mecanismo interno es la clave. Cuando una vista SwiftUI accede a una propiedad de un objeto @Observable, el framework registra ese acceso. Solo cuando esa propiedad específica cambia, la vista se reconstruye. Si una vista solo usa filtro, no se reconstruye cuando cambia tareas:

@Observable
class TareasViewModel {
    var tareas: [Tarea] = []
    var filtro: FiltroTarea = .todas
    var cargando = false
}

// Esta vista solo se reconstruye cuando cambia 'filtro'
struct FiltroView: View {
    var modelo: TareasViewModel // No necesita @ObservedObject ni @StateObject

    var body: some View {
        Picker("Filtro", selection: $modelo.filtro) {
            ForEach(FiltroTarea.allCases) { filtro in
                Text(filtro.nombre).tag(filtro)
            }
        }
    }
}

// Esta vista solo se reconstruye cuando cambia 'tareas'
struct ListaTareasView: View {
    var modelo: TareasViewModel

    var body: some View {
        List(modelo.tareas) { tarea in
            TareaRow(tarea: tarea)
        }
    }
}

Cómo usar @Observable en SwiftUI

Hay tres patrones según el rol de la vista:

// Vista raíz: crea y posee el modelo
struct ContenidoView: View {
    @State private var modelo = TareasViewModel()

    var body: some View {
        ListaTareasView(modelo: modelo)
    }
}

// Vista hija: recibe el modelo como parámetro
struct ListaTareasView: View {
    var modelo: TareasViewModel // Solo 'var', sin wrappers

    var body: some View { ... }
}

// Vista que necesita el modelo de un ancestro
struct ProfundaEnJerarquia: View {
    @Environment(TareasViewModel.self) var modelo

    var body: some View { ... }
}

Para inyectar un modelo en el entorno, usa .environment(_:):

ContenidoView()
    .environment(TareasViewModel())

@Bindable: bindings con @Observable

Para crear un binding bidireccional con una propiedad de un objeto @Observable, necesitas @Bindable:

struct EditarTareaView: View {
    @Bindable var tarea: Tarea // Tarea es @Observable

    var body: some View {
        Form {
            TextField("Título", text: $tarea.titulo)
            Toggle("Completada", isOn: $tarea.completada)
            DatePicker("Fecha límite", selection: $tarea.fechaLimite)
        }
    }
}

El operador $ sobre propiedades de un @Bindable crea bindings directos, igual que con @State.

Propiedades excluidas del seguimiento con @ObservationIgnored

Si una propiedad no debe disparar actualizaciones de vista aunque cambie, usa @ObservationIgnored:

@Observable
class TareasViewModel {
    var tareas: [Tarea] = []

    @ObservationIgnored
    private var cache: [Int: Tarea] = [:] // No dispara actualizaciones

    @ObservationIgnored
    var logger = Logger(subsystem: "com.app", category: "tareas")
}

Ejemplo completo: app de tareas

@Observable
class TareasViewModel {
    var tareas: [Tarea] = []
    var filtro: FiltroTarea = .todas
    var textoBusqueda = ""
    var cargando = false

    var tareasFiltradas: [Tarea] {
        let base = filtro == .todas ? tareas : tareas.filter { filtro.aplica($0) }
        if textoBusqueda.isEmpty { return base }
        return base.filter { $0.titulo.localizedCaseInsensitiveContains(textoBusqueda) }
    }

    func cargar() async {
        cargando = true
        defer { cargando = false }
        do {
            tareas = try await TareasAPI.shared.obtenerTareas()
        } catch {
            print("Error cargando tareas: (error)")
        }
    }

    func completar(_ tarea: Tarea) async {
        guard let idx = tareas.firstIndex(where: { $0.id == tarea.id }) else { return }
        tareas[idx].completada = true
        try? await TareasAPI.shared.actualizar(tareas[idx])
    }
}

struct TareasApp: App {
    @State private var modelo = TareasViewModel()

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                TareasRaizView()
                    .environment(modelo)
            }
        }
    }
}

struct TareasRaizView: View {
    @Environment(TareasViewModel.self) var modelo

    var body: some View {
        @Bindable var modelo = modelo

        List(modelo.tareasFiltradas) { tarea in
            TareaRow(tarea: tarea)
                .swipeActions {
                    Button("Completar") {
                        Task { await modelo.completar(tarea) }
                    }
                    .tint(.green)
                }
        }
        .searchable(text: $modelo.textoBusqueda)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Picker("Filtro", selection: $modelo.filtro) {
                    ForEach(FiltroTarea.allCases) { Text($0.nombre).tag($0) }
                }
                .pickerStyle(.menu)
            }
        }
        .task { await modelo.cargar() }
    }
}

Compatibilidad con versiones anteriores

@Observable requiere iOS 17, macOS 14, watchOS 10 o tvOS 17. Si necesitas compatibilidad con versiones anteriores, puedes combinar ambos enfoques en el mismo proyecto: usa ObservableObject para código compatible con iOS 16 y @Observable para las vistas que solo necesiten iOS 17+. No mezcles los dos enfoques en el mismo objeto.

Resumen

@Observable simplifica el modelo de datos de SwiftUI en tres dimensiones: menos código boilerplate (sin @Published), mejor rendimiento (actualizaciones quirúrgicas por propiedad accedida) y una API más coherente (un solo @State para el estado local y propiedades planas para el paso de modelos). Si desarrollas para iOS 17+ es la forma recomendada de gestionar el estado en SwiftUI.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP