@Observable en SwiftUI: qué cambia realmente frente a ObservableObject en MVVM
Cuando empezamos a migrar ViewModels de ObservableObject al macro @Observable, la primera pregunta del equipo fue si esto era un cambio cosmético —menos boilerplate, mismo comportamiento— o si tocaba entender el modelo de observación desde cero antes de tocar código en producción. Es lo segundo. @Observable no es azúcar sintáctico sobre ObservableObject; cambia a qué nivel de granularidad SwiftUI decide que una vista necesita redibujarse, y eso tiene consecuencias reales en cómo se diseña un ViewModel.
El problema que tenía ObservableObject
Con ObservableObject, la unidad de observación es el objeto completo, no sus propiedades. Cada @Published que cambia dispara objectWillChange.send(), y cualquier vista que se suscribió a ese ViewModel con @ObservedObject o @StateObject se marca como inválida, sin importar si esa vista lee la propiedad que cambió o alguna otra completamente distinta.
class ProfileViewModel: ObservableObject {
@Published var username: String = ""
@Published var bio: String = ""
@Published var followerCount: Int = 0
}
struct UsernameLabel: View {
@ObservedObject var viewModel: ProfileViewModel
var body: some View {
Text(viewModel.username)
}
}
UsernameLabel solo lee username, pero se redibuja cada vez que cambia bio o followerCount, porque la suscripción es al objeto, no a la propiedad. En una pantalla con un ViewModel grande y varias subvistas leyendo campos distintos, esto produce recomposición que no tiene relación con lo que esa vista concreta muestra. El paliativo habitual era partir el ViewModel en varios objetos más pequeños solo para acotar el radio de invalidación, lo cual resolvía el síntoma a costa de fragmentar un estado que conceptualmente pertenecía junto.
Qué hace distinto @Observable
@Observable, introducido con Swift 5.9 y disponible desde iOS 17, cambia el seguimiento a nivel de propiedad. El macro instrumenta cada var almacenada del tipo para registrar, en tiempo de acceso, qué vista leyó qué propiedad dentro de su body. SwiftUI usa ese registro para invalidar solo las vistas que efectivamente leyeron la propiedad que cambió.
@Observable
class ProfileViewModel {
var username: String = ""
var bio: String = ""
var followerCount: Int = 0
}
struct UsernameLabel: View {
var viewModel: ProfileViewModel
var body: some View {
Text(viewModel.username)
}
}
Nótese que ya no hace falta @Published en cada propiedad ni @ObservedObject en la vista: @Observable lo asume por defecto para toda propiedad almacenada, y la vista simplemente guarda una referencia normal. UsernameLabel ahora solo se redibuja cuando username cambia. Un cambio en bio o followerCount no la afecta, sin necesidad de partir el ViewModel en piezas artificiales. Esto no es una optimización que haya que activar: es el comportamiento por defecto del macro, y es la razón principal por la que vale la pena migrar ViewModels con muchas propiedades y varias subvistas consumiéndolos de forma parcial.
@State vs @Bindable: cuándo usar cada uno
Esta es la parte que más confusión genera al migrar, porque @StateObject cubría un solo caso y ahora hay que elegir entre dos property wrappers según quién es dueño del ciclo de vida del objeto.
@State es para cuando la vista es dueña de la instancia del ViewModel, exactamente el rol que antes cumplía @StateObject. SwiftUI crea la instancia una sola vez y la conserva mientras la vista permanece en el árbol, sobreviviendo a recomposiciones:
struct ProfileScreen: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
UsernameLabel(viewModel: viewModel)
}
}
@Bindable es para cuando la vista no crea el objeto —lo recibe como parámetro, típicamente desde una vista padre— pero necesita crear un Binding hacia una de sus propiedades, por ejemplo para pasárselo a un TextField:
struct EditBioView: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Biografía", text: $viewModel.bio)
}
}
Sin @Bindable, $viewModel.bio no compila, porque una propiedad simple no expone la sintaxis $. El error común en migraciones es usar @Bindable en todos lados por costumbre de traer @ObservedObject de vuelta: si la vista no necesita generar un Binding hacia una propiedad, una var normal alcanza y es más barata de razonar.
Qué patrones anteriores quedan obsoletos
Tres cosas que eran necesarias con ObservableObject dejan de tener sentido con @Observable:
objectWillChange manual. Cualquier lógica que llamaba a objectWillChange.send() a mano para forzar una actualización —típico en propiedades computadas que dependían de estado interno no marcado con @Published— desaparece, porque el macro instrumenta el acceso directamente y no necesita esa señal explícita.
ViewModels anidados con @Published de objetos observables. Un patrón habitual era @Published var address: Address donde Address también era ObservableObject, y había que reenviar manualmente los cambios internos con Combine para que se propagaran hacia arriba. Con @Observable, un tipo anidado que también use @Observable propaga sus cambios de forma automática sin ningún cableado adicional, siempre que sea una propiedad almacenada directa y no un elemento dentro de una colección.
División artificial de ViewModels grandes. Como se explicó arriba, ya no hace falta partir un ViewModel en varios objetos solo para limitar qué vistas se invalidan. Esto simplifica jerarquías que antes se ramificaban por razones de rendimiento, no de dominio.
Lo que no se resuelve solo
La migración no es gratis en todos los casos. @Observable requiere iOS 17 como mínimo, así que cualquier app con soporte a versiones anteriores no puede migrar sin condicionar el código o levantar el target mínimo, y esa decisión suele tardar más en aprobarse que la migración técnica en sí.
El seguimiento por propiedad tampoco cubre colecciones de forma transparente: un var items: [Item] donde Item es una clase @Observable no propaga automáticamente los cambios internos de cada elemento hacia las vistas que iteran sobre el arreglo, porque el Array en sí es un value type y el tracking del macro opera sobre las propiedades del tipo que lo declara, no sobre el contenido de cada elemento por separado. Si una vista necesita reaccionar a cambios dentro de un elemento de la colección, sigue haciendo falta pasar la referencia de ese elemento en particular a una subvista que lo lea directamente.
Por último, el macro no es compatible sin fricción con Combine cuando el ViewModel necesita combinar streams asíncronos de varias fuentes con operadores como debounce o combineLatest. @Observable resuelve el problema de exponer estado a la vista, no el de componer flujos asíncronos; para eso, la lógica de Combine sigue viviendo dentro del ViewModel exactamente igual que antes, y sus resultados se asignan a propiedades normales que el macro ya sabe observar.
Cuándo migrar
Migramos primero los ViewModels grandes con varias subvistas leyendo subconjuntos distintos de sus propiedades, porque ahí la ganancia en precisión de recomposición es real y verificable con el Instruments de SwiftUI. En ViewModels pequeños con una sola vista consumidora, la migración simplifica el código —menos property wrappers, menos ceremonia— pero no cambia el comportamiento de forma perceptible, así que no es prioritaria. La regla que nos funciona es tratar la migración como una decisión por ViewModel, no como un cambio de framework que haya que aplicar de golpe a todo el proyecto.