BS
BleepingSwift
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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.