Swift and SwiftUI tutorials for Swift Developers

@Observable vs @Published in SwiftUI

If you’ve been developing applications for the Apple ecosystem over the last few years, state management has likely been your main source of headaches. SwiftUI, in its original conception, relied heavily on the Combine framework to communicate data changes to the user interface. Keywords like ObservableObject@Published, and @StateObject became our daily bread.

However, with the arrival of Swift 5.9 (iOS 17, macOS 14, watchOS 10), Apple introduced a tectonic shift: the Observation framework and the @Observable macro.

This tutorial is not just a superficial comparison; it is a technical deep dive into the architectural, performance, and syntactical differences between the “Old World” (@Published) and the “New World” (@Observable). You will learn why Apple made this change and how you can leverage it to create faster, cleaner apps in Xcode.


Part 1: Understanding the Contenders

To understand the differences, we must first dissect how each system works under the hood.

The Veteran: ObservableObject and @Published

In the early versions of SwiftUI (iOS 13-16), reactivity was based on the ObservableObject protocol.

When we conform a class to this protocol, the compiler synthesizes an objectWillChange publisher. But how does this publisher know when to fire? Enter @Published.

@Published is a property wrapper. It wraps your property and adds a hook in the willSet. Every time you assign a new value to a property marked with @Published, it notifies the parent object: “Hey, I’m about to change! Tell the view.”

The fundamental problem: Granularity. The notification is at the Object level, not the property level.

The Challenger: The @Observable Macro

The new system utilizes the new Swift Macros. When you annotate a class with @Observable, you are not using a runtime property wrapper. You are instructing the compiler to rewrite your class at compile time.

The macro injects logic into the getters and setters of all stored properties. SwiftUI no longer “listens” to the entire object; instead, it creates a precise Dependency Graph. If a view reads a property, it subscribes to that specific property.


Part 2: Critical Architectural Differences

Here is where we get into the technical substance. The difference between the two is not just syntax; it is behavior.

1. The Over-invalidation Problem

This is the number one reason why Apple created @Observable.

Scenario with @Published: Imagine a UserProfileViewModel with two properties:

class UserProfile: ObservableObject {
    @Published var username: String = "Dev"
    @Published var heartRate: Int = 60 // Changes 100 times per minute
}

And you have a view that only displays the name:

struct NameView: View {
    @ObservedObject var model: UserProfile
    var body: some View {
        Text(model.username)
    }
}

What happens: Every time heartRate changes (even though the view doesn’t use it), objectWillChange fires. SwiftUI invalidates NameView, calculates the body, compares the result, and realizes nothing visual changed. This is a massive waste of CPU cycles.

Scenario with @Observable:

@Observable class UserProfile {
    var username: String = "Dev"
    var heartRate: Int = 60
}

What happens: When NameView renders for the first time, it reads model.username. The observation system registers: “NameView depends on UserProfile.username”. If heartRate changes, the system looks at the dependency graph, sees that no one is observing heartRate in this view, and does nothing. The view doesn’t even know it happened.

2. Syntax and “Boilerplate”

The reduction in visual noise is drastic. Let’s see a direct code comparison.

The Old Style (Combine)

import SwiftUI

class SettingsModel: ObservableObject {
    @Published var isDarkMode: Bool = false
    @Published var volume: Double = 0.5
    
    // Computed properties do not emit changes automatically
    // unless they depend on @Published properties
}

struct SettingsView: View {
    @StateObject private var model = SettingsModel()
    
    var body: some View {
        Toggle("Dark Mode", isOn: $model.isDarkMode)
    }
}

The New Style (Observation)

import SwiftUI
import Observation // Important if you don't import SwiftUI

@Observable 
class SettingsModel {
    var isDarkMode: Bool = false
    var volume: Double = 0.5
    // No need to mark anything as @Published
}

struct SettingsView: View {
    // Goodbye @StateObject, hello @State
    @State private var model = SettingsModel()
    
    var body: some View {
        // Direct binding
        Toggle("Dark Mode", isOn: $model.isDarkMode)
    }
}

3. Dependency Injection (The End of “Wrapper Soup”)

With @Published, we had strict and confusing rules about which wrapper to use in the view:

  • Creation: @StateObject (so the object survives redraws).
  • Observation: @ObservedObject (if someone else created it).
  • Environment: @EnvironmentObject.

If you made a mistake and used @ObservedObject where you should have used @StateObject, your model would reset randomly.

With @Observable, everything is simplified:

  • Creation: Use @State. Yes, @State now manages the lifecycle of observable objects.
  • Observation: Use normal let or var. You don’t need a wrapper. SwiftUI automatically detects that it is an observable object.
  • Bindings: Use @Bindable if you need the $ symbol (we will explain this later).
  • Environment: Use @Environment(YourClass.self).

Part 3: Deep Dive into Computed Properties

One of the subtlest but most powerful differences lies in how computed properties are handled.

With @Published: If you had a computed property that depended on other variables, @Published worked fine as long asthe dependencies were also @Published. But if your computed property depended on something external or complex, you often had to manually call objectWillChange.send().

With @Observable: The system is smart. It tracks dependencies recursively.

@Observable class Cart {
    var items: [Item] = []
    
    // This property is automatically observed
    var total: Double {
        items.reduce(0) { $0 + $1.price }
    }
}

If you add an item to the array, total changes. Any view displaying total will update. No extra code required.


Part 4: The Role of @Bindable (The New Player)

This is where many developers get confused when migrating.

In the old system, @ObservedObject automatically projected a Binding (via $model). In the new system, because we pass the object as a simple let or var, we don’t have that automatic projection.

If you need to pass a value to a TextField or a Toggle (which require write access), you need to use the @Bindable wrapper.

Tutorial on using @Bindable:

  1. At the root: You create the object with @State. You access the binding with $.
@State private var user = User()
TextField("Name", text: $user.name) // Works

2. In a child view: You receive the “raw” object.

struct EditView: View {
    var user: User // Received normally

    var body: some View {
        // Error: You cannot use $user
        // TextField("Name", text: $user.name) 

        // Solution: Create a Bindable on the fly
        @Bindable var bUser = user
        TextField("Name", text: $bUser.name)
    }
}

You can also declare @Bindable var user: User in the view arguments if you will always need bindings.


Part 5: Performance and Collections

Handling collections (Arrays of objects) was always a pain point with ObservableObject. If you had an Array of observable objects, changing a property inside one of those objects did not update the view showing the list, unless you manually invalidated the array.

With @Observable, propagation is much more fluid, but it requires understanding that observation happens at access.

If you have: var users: [User] (where User is @Observable)

And your view does:

ForEach(users) { user in
    Text(user.name)
}

SwiftUI tracks:

  1. The users array (if you add/remove elements).
  2. The name property of each visible User instance.

This allows for enormously efficient lists where updating the name of user #50 only repaints that specific cell, without touching the rest of the list or the container structure.


Part 6: Step-by-Step Migration Guide

Do you have a large app in Xcode with ObservableObject? Don’t panic. You don’t need to rewrite everything today.

Coexistence Strategy

Both systems can coexist in the same app, but not in the same view/object.

  1. Do not mix: An object cannot be ObservableObject and @Observable at the same time.
  2. Hybrid Views: A view can have a @StateObject (old) and a @State (new) from two different models. It will work.

Steps to Refactor a Model

  1. Change the declaration:
    • Remove : ObservableObject.
    • Add @Observable before class.
    • Import Observation.
  2. Clean up the properties:
    • Remove all @Published. Simply leave them as var.
  3. Update the Views:
    • Change @StateObject var model = ... to @State var model = ....
    • Change @ObservedObject var model: Type to let model: Type (or var).
    • If you use bindings ($model.property), add @Bindable var model in the view body or declaration.
    • Change @EnvironmentObject to @Environment(Type.self).

Part 7: When NOT to use @Observable?

Despite all the advantages, there is an insurmountable wall: Compatibility.

The Observation framework is only available on:

  • iOS 17.0+
  • macOS 14.0+
  • watchOS 10.0+
  • tvOS 17.0+

If your client or company requires support for iOS 15 or iOS 16you cannot use @Observable (unless you use #available and maintain two versions of the code, which is usually not practical for the model layer).

In this case, @Published and ObservableObject remain perfectly valid tools, stable and supported by Apple. They are not “deprecated” in the sense that they will disappear soon; they are simply the “legacy” technology.


Part 8: Technical Summary

To close this tutorial, here is a quick reference table to keep on your desk while programming in Xcode.

FeatureObservableObject (@Published)@Observable (Macro)
MechanismCombine (Publisher/Subscriber)Swift Runtime & Macros
GranularityEntire Object (Any change notifies)Individual Property (Only used notifies)
Declarationclass A: ObservableObject@Observable class A
Mark Properties@Published var xvar x (Automatic)
Ignore Propertiesvar x (Without @Published)@ObservationIgnored var x
In View (Owner)@StateObject@State
In View (Reader)@ObservedObjectlet / var
BindingsAutomatic ($object.prop)Manual (@Bindable or Bindable())
Min RequirementiOS 13iOS 17

Conclusion

The transition from @Published to @Observable represents the maturity of SwiftUI. Apple has removed the dependency on Combine for the view layer, recognizing that the reactive stream model is not always the best model for interface State.

@Observable is more “Swift-native”. It feels less like an added library and more like an intrinsic feature of the language. It reduces boilerplate, eliminates common performance bugs, and simplifies teaching SwiftUI to new developers.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

@Observable in SwiftUI for iOS, macOS and watchOS

Next Article

SwiftUI Picker

Related Posts