If you’ve built anything with SwiftUI before iOS 17, you know the ritual. Conform to ObservableObject. Mark every property with @Published. Use @StateObject for creation, @ObservedObject for injection, and hope you picked the right one. It works, but there’s a lot of ceremony for what should be simple: “this object has state, and views should update when it changes.”

@Observable cuts through all of that. One macro, no wrappers, and your views only re-render when the specific properties they read actually change.

Think of It Like a Newspaper Subscription

With ObservableObject, your views subscribe to the entire newspaper. Any property changes, and every subscribed view gets the full paper delivered - even if they only care about the sports section. That’s why you’d see views re-rendering when unrelated properties changed.

@Observable flips this. Each view subscribes only to the columns it actually reads. The view that shows the username only updates when the username changes. The view that shows the profile image doesn’t flinch.

What You Had Before

Here’s the classic ObservableObject setup:

class UserViewModel: ObservableObject {
    @Published var username = ""
    @Published var isLoggedIn = false
    @Published var profileImage: UIImage?

    func login() {
        isLoggedIn = true
    }
}

struct ContentView: View {
    // Must use @StateObject for creation, @ObservedObject for injection
    @StateObject private var viewModel = UserViewModel()

    var body: some View {
        Text(viewModel.username)
    }
}

Four things you have to get right: the protocol conformance, @Published on each property, the correct property wrapper in the view, and remembering which wrapper to use where. Miss one and you get silent bugs - views that don’t update, or objects that get recreated on every render.

What You Have Now

Same thing with @Observable:

@Observable
class UserViewModel {
    var username = ""
    var isLoggedIn = false
    var profileImage: UIImage?

    func login() {
        isLoggedIn = true
    }
}

struct ContentView: View {
    var viewModel = UserViewModel()

    var body: some View {
        // SwiftUI tracks that this view reads 'username'
        // Changes to isLoggedIn or profileImage won't trigger a re-render here
        Text(viewModel.username)
    }
}

No protocol. No @Published. No @StateObject. Just a class with a macro. SwiftUI figures out which properties each view actually reads and only updates when those change.

Computed Properties Work Automatically

One of the nicest parts - computed properties just work. SwiftUI tracks through them to the stored properties underneath:

@Observable
class ShoppingCartViewModel {
    var items: [CartItem] = []
    var discountCode: String = ""

    // SwiftUI knows this depends on 'items'
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price }
    }

    // And this depends on both 'discountCode' and 'totalPrice' (through 'items')
    var discountedTotal: Double {
        let discount = calculateDiscount(for: discountCode)
        return totalPrice * (1 - discount)
    }
}

A view showing discountedTotal updates when items or discountCode change - but not when some other unrelated property changes. With ObservableObject, you’d have needed manual objectWillChange calls or extra @Published gymnastics to get this right.

Opting Properties Out with @ObservationIgnored

Sometimes a property shouldn’t trigger view updates. Mark it with @ObservationIgnored:

@Observable
class AnalyticsViewModel {
    var userActions: [Action] = []

    // This is infrastructure, not UI state - no need to trigger re-renders
    @ObservationIgnored
    var analyticsService = AnalyticsService()

    func trackAction(_ action: Action) {
        userActions.append(action)
        analyticsService.log(action)
    }
}

This is useful for services, caches, or any internal state that views don’t need to know about.

Passing State Through the Environment

@Observable objects work directly with SwiftUI’s environment - no @EnvironmentObject wrapper needed:

@Observable
class AppState {
    var currentUser: User?
    var theme: Theme = .light
}

@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        Text(appState.currentUser?.name ?? "Guest")
    }
}

Notice: .environment(appState) instead of .environmentObject(appState), and @Environment(AppState.self) instead of @EnvironmentObject. It’s a small syntax change, but it means one less wrapper to think about.

Migrating Existing Code

If you’re moving from ObservableObject, here’s the checklist:

  • Remove ObservableObject conformance and add @Observable to the class
  • Remove all @Published wrappers - plain var is all you need
  • Replace @StateObject with @State (for owned instances)
  • Replace @ObservedObject with a plain property (for injected instances)
  • Replace @EnvironmentObject with @Environment(YourType.self)
  • Add @ObservationIgnored to properties that shouldn’t trigger updates
  • Test your views - the update behavior is more granular now, which is usually better but occasionally means a view that used to update “by accident” now doesn’t

Key Takeaways

  1. One macro replaces four concepts - @Observable eliminates ObservableObject, @Published, @StateObject, and @ObservedObject
  2. Views track what they read - only the properties a view actually accesses trigger re-renders. No more “the whole newspaper” updates
  3. Computed properties just work - SwiftUI traces dependencies through them automatically
  4. @ObservationIgnored is your opt-out - use it for services, caches, and internal state that views don’t care about
  5. Migration is mechanical - it’s mostly removing wrappers and conformances. The hard part is testing that everything still updates correctly