Home / Blog / Observation framework: when I stopped reaching for @Published

Observation framework: when I stopped reaching for @Published

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, […]

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.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch