@Observable in SwiftUI: What Actually Changes vs. ObservableObject in MVVM
When we started migrating ViewModels from ObservableObject to the @Observable macro, the first question from the team was whether this was a cosmetic change — less boilerplate, same behavior — or whether it required rethinking the observation model from scratch before touching production code. It's the latter. @Observable isn't syntactic sugar over ObservableObject; it changes the granularity at which SwiftUI decides a view needs to redraw, and that has real consequences for how a ViewModel gets designed.
The problem with ObservableObject
With ObservableObject, the unit of observation is the whole object, not its individual properties. Every @Published change fires objectWillChange.send(), and any view subscribed to that ViewModel through @ObservedObject or @StateObject gets invalidated, regardless of whether that view reads the property that changed or some entirely different one.
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 only reads username, but it redraws every time bio or followerCount changes, because the subscription is to the object, not to the property. On a screen with a large ViewModel and several subviews reading different fields, this produces recomposition that has nothing to do with what that particular view actually shows. The usual workaround was splitting the ViewModel into several smaller objects just to narrow the blast radius of invalidation — which fixed the symptom at the cost of fragmenting state that conceptually belonged together.
What @Observable does differently
@Observable, introduced with Swift 5.9 and available from iOS 17 onward, moves tracking to the property level. The macro instruments every stored var on the type to record, at access time, which view read which property inside its body. SwiftUI uses that record to invalidate only the views that actually read the property that changed.
@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)
}
}
Notice there's no more @Published on each property, and no @ObservedObject on the view: @Observable treats every stored property as observed by default, and the view just holds a plain reference. UsernameLabel now only redraws when username changes. A change to bio or followerCount doesn't touch it, with no need to split the ViewModel into artificial pieces. This isn't an optimization you opt into — it's the macro's default behavior, and it's the main reason it's worth migrating ViewModels with many properties consumed piecemeal by several subviews.
@State vs @Bindable: which one, when
This is where migrations get confusing, because @StateObject used to cover a single case, and now you have to choose between two property wrappers depending on who owns the object's lifecycle.
@State is for when the view owns the ViewModel instance — the same role @StateObject used to play. SwiftUI creates the instance once and keeps it alive as long as the view stays in the tree, surviving recomposition:
struct ProfileScreen: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
UsernameLabel(viewModel: viewModel)
}
}
@Bindable is for when the view doesn't create the object — it receives it as a parameter, typically from a parent view — but needs a Binding into one of its properties, for example to hand it to a TextField:
struct EditBioView: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Bio", text: $viewModel.bio)
}
}
Without @Bindable, $viewModel.bio doesn't compile, because a plain property doesn't expose $ syntax. The common migration mistake is reaching for @Bindable everywhere out of habit from @ObservedObject: if the view never needs to produce a Binding into a property, a plain var is enough, and it's cheaper to reason about.
Which old patterns become obsolete
Three things that were necessary with ObservableObject stop making sense with @Observable:
Manual objectWillChange calls. Any logic that called objectWillChange.send() by hand to force an update — typically in computed properties that depended on internal state not marked @Published — goes away, because the macro instruments access directly and doesn't need that explicit signal.
Nested ViewModels wired through @Published observable objects. A common pattern was @Published var address: Address, where Address was itself an ObservableObject, requiring manual Combine forwarding so internal changes propagated upward. With @Observable, a nested type that's also @Observable propagates its changes automatically with no extra wiring, as long as it's a direct stored property and not an element inside a collection.
Splitting large ViewModels artificially. As covered above, there's no longer a need to break a ViewModel into several objects just to limit which views get invalidated. This flattens hierarchies that used to branch for performance reasons rather than domain ones.
What doesn't get solved on its own
The migration isn't free in every case. @Observable requires iOS 17 as a floor, so any app still supporting earlier versions can't migrate without conditioning the code or raising the minimum target — and that decision usually takes longer to get approved than the technical migration itself.
Property-level tracking also doesn't cover collections transparently: a var items: [Item] where Item is an @Observable class doesn't automatically propagate each element's internal changes to views iterating over the array, because the Array itself is a value type and the macro's tracking operates on the properties of the type that declares it, not on the contents of each individual element. If a view needs to react to changes inside one element of the collection, you still need to pass that specific element's reference down to a subview that reads it directly.
Finally, the macro doesn't remove friction with Combine when a ViewModel needs to combine asynchronous streams from multiple sources using operators like debounce or combineLatest. @Observable solves the problem of exposing state to the view, not the problem of composing asynchronous flows; for that, Combine logic keeps living inside the ViewModel exactly as before, and its results get assigned to plain properties the macro already knows how to observe.
When to migrate
We migrate large ViewModels first — the ones with several subviews each reading a different subset of their properties — because that's where the gain in recomposition precision is real and verifiable with SwiftUI's Instruments. On small ViewModels with a single consuming view, migrating simplifies the code — fewer property wrappers, less ceremony — but doesn't change behavior in any noticeable way, so it isn't a priority. The rule that works for us is treating migration as a per-ViewModel decision, not a framework switch to apply across the whole project at once.