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,@Statenow manages the lifecycle of observable objects. - Observation: Use normal
letorvar. You don’t need a wrapper. SwiftUI automatically detects that it is an observable object. - Bindings: Use
@Bindableif 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:
- At the root: You create the object with
@State. You access the binding with$.
@State private var user = User()
TextField("Name", text: $user.name) // Works2. 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:
- The
usersarray (if you add/remove elements). - The
nameproperty of each visibleUserinstance.
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.
- Do not mix: An object cannot be
ObservableObjectand@Observableat the same time. - 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
- Change the declaration:
- Remove
: ObservableObject. - Add
@Observablebeforeclass. - Import
Observation.
- Remove
- Clean up the properties:
- Remove all
@Published. Simply leave them asvar.
- Remove all
- Update the Views:
- Change
@StateObject var model = ...to@State var model = .... - Change
@ObservedObject var model: Typetolet model: Type(orvar). - If you use bindings (
$model.property), add@Bindable var modelin the view body or declaration. - Change
@EnvironmentObjectto@Environment(Type.self).
- Change
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 16, you 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.
| Feature | ObservableObject (@Published) | @Observable (Macro) |
| Mechanism | Combine (Publisher/Subscriber) | Swift Runtime & Macros |
| Granularity | Entire Object (Any change notifies) | Individual Property (Only used notifies) |
| Declaration | class A: ObservableObject | @Observable class A |
| Mark Properties | @Published var x | var x (Automatic) |
| Ignore Properties | var x (Without @Published) | @ObservationIgnored var x |
| In View (Owner) | @StateObject | @State |
| In View (Reader) | @ObservedObject | let / var |
| Bindings | Automatic ($object.prop) | Manual (@Bindable or Bindable()) |
| Min Requirement | iOS 13 | iOS 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.