I started 2025 with a greenfield project built on the iOS 17 Observation framework. Here’s what reaching for @Observable over ObservableObject and @Published actually bought me, and what I gave up.
The motivation: unnecessary re-renders
With ObservableObject, any property change fires objectWillChange and the entire view re-renders. On a view model with ten @Published properties, a view that only reads one of them still redraws:
class OldVM: ObservableObject {
@Published var query = ""
@Published var results: [Item] = []
@Published var isLoading = false
// ... 7 more
}
struct SearchBar: View {
@ObservedObject var vm: OldVM
var body: some View {
TextField("search", text: $vm.query)
// re-renders when isLoading changes too
}
}Observation tracks per property. SwiftUI only subscribes to the properties that are actually read:
@Observable class NewVM {
var query = ""
var results: [Item] = []
var isLoading = false
}
struct SearchBar: View {
let vm: NewVM // no @ObservedObject
var body: some View {
TextField("search", text: Bindable(vm).query)
}
}The measured win
On a complex list screen with 50 rows backed by a single shared view model, tapping a checkbox on one row used to re-render all 50 with @Published. With @Observable only the affected row updates. In Instruments, frame drops were down 60%.
Using Bindable
The $vm.property binding syntax from @Published comes back through the @Bindable property wrapper:
struct EditView: View {
@Bindable var user: User
var body: some View {
TextField("name", text: $user.name)
}
}If you get the object as a parameter, create a local Bindable inside the function scope:
struct Cell: View {
let user: User
var body: some View {
@Bindable var bindable = user
TextField("name", text: $bindable.name)
}
}Swapping Environment
The old code used @EnvironmentObject. Observation gives you a cleaner API via @Environment:
@Observable class AppState { var user: User? }
@main struct MyApp: App {
@State var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView().environment(appState)
}
}
}
struct ContentView: View {
@Environment(AppState.self) var appState
var body: some View { Text(appState.user?.name ?? "") }
}Integrating with Combine
@Published gave you a publisher for free, so you could build Combine pipelines off it. @Observable doesn’t. To turn an observable property into a publisher, you do the work by hand:
import Observation
import Combine
let subject = PassthroughSubject<String, Never>()
withObservationTracking {
_ = vm.query
} onChange: {
subject.send(vm.query)
}The pattern needs to be rebound each time (call withObservationTracking again after every change). A little more manual.
Habit I dropped: @StateObject vs @State
ObservableObject required @StateObject so the instance survived for the view’s lifetime. @Observable is happy with @State:
struct Screen: View {
@State private var vm = MyVM()
// used to be @StateObject private var vm = MyVM()
}iOS 17 minimum
Observation is iOS 17 and up. If you still ship to older versions you’re stuck on ObservableObject or on conditional compilation with two code paths. My preference: if I can push the deployment target to iOS 17, I do. iOS 16 share is small past mid-2026.
Pitfall: computed property tracking
Observation doesn’t track a computed property on its own. It tracks the stored properties the computed one reads:
@Observable class VM {
var items: [Item] = []
var activeCount: Int { items.filter { $0.isActive }.count }
}When a view reads vm.activeCount, Observation subscribes to items. That’s usually the right behaviour, but when you’re debugging tracking it can confuse you.
Closing notes
@Observable is my default on new projects. On existing ones I migrate gradually; ObservableObject and @Observable coexist on the same screen without issue. I didn’t hit any bugs during the transition; the API surface is compatible. The one loss is Combine publishers, but I was already reaching for them less since moving to async/await. Net win.