- Published on
- 7 min read
> @Observable Macro in SwiftUI: Migrating from ObservableObject
iOS 17 introduced the @Observable macro as part of Swift's new Observation framework. It replaces the older ObservableObject protocol with a more ergonomic and performant system for reactive state management in SwiftUI.
The Old Way: ObservableObject
Before iOS 17, you created observable classes using the ObservableObject protocol and marked individual properties with @Published:
import SwiftUI
class UserSettings: ObservableObject {
@Published var username: String = ""
@Published var notificationsEnabled: Bool = true
@Published var theme: Theme = .system
}
struct SettingsView: View {
@StateObject private var settings = UserSettings()
var body: some View {
Form {
TextField("Username", text: $settings.username)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
}
}
}
This worked, but had some friction. You needed @Published on every property you wanted to observe, and forgetting it meant silent bugs where views wouldn't update. You also had to choose between @StateObject, @ObservedObject, and @EnvironmentObject depending on ownership semantics.
The New Way: @Observable
With @Observable, the same code becomes simpler:
import SwiftUI
@Observable
class UserSettings {
var username: String = ""
var notificationsEnabled: Bool = true
var theme: Theme = .system
}
struct SettingsView: View {
@State private var settings = UserSettings()
var body: some View {
Form {
TextField("Username", text: $settings.username)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
}
}
}
The @Observable macro automatically tracks all stored properties. No more @Published. And instead of @StateObject, you use the familiar @State.
Key Differences at a Glance
The shift from ObservableObject to @Observable changes several patterns:
Class Declaration
// Before
class ViewModel: ObservableObject {
@Published var count = 0
}
// After
@Observable
class ViewModel {
var count = 0
}
View Ownership
// Before: @StateObject for ownership
@StateObject private var viewModel = ViewModel()
// After: @State works for reference types
@State private var viewModel = ViewModel()
Passing to Child Views
// Before: @ObservedObject
@ObservedObject var viewModel: ViewModel
// After: just a regular property
var viewModel: ViewModel
Environment Injection
// Before
.environmentObject(settings)
@EnvironmentObject var settings: Settings
// After
.environment(settings)
@Environment(Settings.self) var settings
Why @Observable is Better
The new system brings several improvements beyond cleaner syntax.
Automatic Property Tracking
Every stored property is observable by default. You don't need to remember @Published, and computed properties that depend on stored properties automatically trigger updates when their dependencies change.
@Observable
class ShoppingCart {
var items: [Item] = []
var total: Double {
items.reduce(0) { $0 + $1.price }
}
}
When items changes, any view reading total will update automatically.
Fine-Grained Updates
With ObservableObject, changing any @Published property triggered updates in all views observing that object. With @Observable, SwiftUI tracks exactly which properties each view reads and only updates views that actually depend on the changed property.
@Observable
class Profile {
var name: String = ""
var bio: String = ""
var avatarURL: URL?
}
struct NameBadge: View {
var profile: Profile
var body: some View {
Text(profile.name) // Only updates when name changes
}
}
struct BioSection: View {
var profile: Profile
var body: some View {
Text(profile.bio) // Only updates when bio changes
}
}
This leads to better performance in complex apps where multiple views observe the same model.
Simpler Property Wrappers
You no longer need to choose between @StateObject and @ObservedObject. Use @State when a view owns the object, and pass it as a regular property to children:
struct ParentView: View {
@State private var viewModel = ViewModel()
var body: some View {
ChildView(viewModel: viewModel)
}
}
struct ChildView: View {
var viewModel: ViewModel // No wrapper needed
var body: some View {
Text(viewModel.title)
}
}
Migrating Your Code
Here's a step-by-step process for migrating from ObservableObject to @Observable.
Step 1: Update the Class
Replace the protocol conformance with the macro and remove @Published:
// Before
class TaskStore: ObservableObject {
@Published var tasks: [Task] = []
@Published var isLoading: Bool = false
func load() async {
isLoading = true
tasks = await fetchTasks()
isLoading = false
}
}
// After
@Observable
class TaskStore {
var tasks: [Task] = []
var isLoading: Bool = false
func load() async {
isLoading = true
tasks = await fetchTasks()
isLoading = false
}
}
Step 2: Update View Ownership
Replace @StateObject with @State:
// Before
struct TaskListView: View {
@StateObject private var store = TaskStore()
...
}
// After
struct TaskListView: View {
@State private var store = TaskStore()
...
}
Step 3: Update Child Views
Replace @ObservedObject with regular properties:
// Before
struct TaskRow: View {
@ObservedObject var store: TaskStore
let taskID: UUID
...
}
// After
struct TaskRow: View {
var store: TaskStore
let taskID: UUID
...
}
Step 4: Update Environment Usage
Replace @EnvironmentObject with @Environment:
// Before
struct SomeView: View {
@EnvironmentObject var settings: AppSettings
...
}
// Injected with:
.environmentObject(settings)
// After
struct SomeView: View {
@Environment(AppSettings.self) var settings
...
}
// Injected with:
.environment(settings)
Bindable for Two-Way Bindings
When you need a binding to a property in an @Observable object that wasn't declared with @State, use @Bindable:
struct EditorView: View {
@Bindable var document: Document
var body: some View {
TextField("Title", text: $document.title)
}
}
If the object comes from @State, you can create bindings directly:
struct ContainerView: View {
@State private var document = Document()
var body: some View {
TextField("Title", text: $document.title) // Works directly
}
}
Excluding Properties from Observation
Sometimes you have properties that shouldn't trigger view updates. Use @ObservationIgnored:
@Observable
class MediaPlayer {
var currentTrack: Track?
var isPlaying: Bool = false
@ObservationIgnored
var internalCache: [String: Data] = [:]
@ObservationIgnored
private var audioEngine: AudioEngine?
}
Changes to ignored properties won't cause any view updates.
Working with Computed Properties
Computed properties work naturally with @Observable. They're automatically observable when they depend on stored properties:
@Observable
class TimerModel {
var seconds: Int = 0
var formattedTime: String {
let minutes = seconds / 60
let secs = seconds % 60
return String(format: "%02d:%02d", minutes, secs)
}
}
struct TimerView: View {
var timer: TimerModel
var body: some View {
Text(timer.formattedTime) // Updates when seconds changes
}
}
Supporting Older iOS Versions
If you need to support iOS 16 and earlier, you'll need to maintain both patterns. One approach is to use a protocol:
protocol SettingsProvider {
var username: String { get set }
var theme: Theme { get set }
}
// For iOS 17+
@Observable
class ModernSettings: SettingsProvider {
var username: String = ""
var theme: Theme = .system
}
// For iOS 16 and earlier
class LegacySettings: ObservableObject, SettingsProvider {
@Published var username: String = ""
@Published var theme: Theme = .system
}
Or you can use conditional compilation, though this gets verbose quickly. For most apps, setting a minimum deployment target of iOS 17 is the cleaner path forward.
Common Pitfalls
Forgetting @Bindable
If you pass an @Observable object to a child view and need bindings, remember to use @Bindable:
struct ChildView: View {
@Bindable var model: SomeModel // Needed for $model.property
var body: some View {
TextField("Name", text: $model.name)
}
}
Mixing Old and New
Don't mix @Observable with ObservableObject patterns. Pick one:
// Wrong: mixing patterns
@Observable
class BadExample: ObservableObject { // Don't do this
@Published var value = 0 // @Published does nothing with @Observable
}
Unnecessary @State on Child Views
Child views receiving an @Observable object don't need any wrapper:
// Wrong: unnecessary @State
struct ChildView: View {
@State var model: SomeModel // Creates a new copy
var body: some View { ... }
}
// Right: just a property
struct ChildView: View {
var model: SomeModel
var body: some View { ... }
}
The @Observable macro is the future of state management in SwiftUI. It reduces boilerplate, improves performance through fine-grained updates, and makes the mental model simpler. If your app targets iOS 17 or later, migrating from ObservableObject is straightforward and worth the effort.
// Continue_Learning
@State vs @Binding in SwiftUI: When to Use Each
Understand the difference between @State and @Binding in SwiftUI, when to use each, and how they work together to manage data flow between views.
Working with Optional ObservableObject in SwiftUI
Learn different approaches for handling optional ObservableObject instances in SwiftUI, from conditional rendering to wrapper patterns.
Detecting When App Enters Background and Saving State in SwiftUI
Learn how to detect app lifecycle changes in SwiftUI, save state when your app enters the background, and restore it when users return, using scenePhase and UIApplication notifications.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.