Skip to main content

@Observable (iOS 17+) in SwiftUI

Replace ObservableObject with the Observation framework

Updated 4 min readMarkdown
@Observable (iOS 17+) in SwiftUI

Full code available on here.

Since iOS 17, the @Observable macro lets you build observable models without ObservableObject, @Published, @StateObject or @ObservedObject. In this post we build a small task list that uses @Observable, @Bindable and @State to show how the new model works.

Why use @Observable?

  1. Less boilerplate. No @Published on every property. Every stored property is tracked automatically.
  2. More precise updates. A view re-renders only when a property it actually reads changes. With ObservableObject, any @Published change refreshed every view observing the object.
  3. Simpler property wrappers. @State owns the model, and @Bindable creates bindings. You no longer need @StateObject, @ObservedObject or @EnvironmentObject for the common cases.

Step-by-step implementation

Step 1: Create an @Observable model

@available(iOS 17, *)
@Observable
class Task: Identifiable {
    let id = UUID()
    var title: String
    var isDone = false

    init(title: String) {
        self.title = title
    }
}

Add @Observable to a class (it must be a class) and import Observation. There is no ObservableObject conformance and no @Published. Each stored property is observable.

Step 2: Create a store

@available(iOS 17, *)
@Observable
class TaskStore {
    var tasks: [Task] = [
        Task(title: "Read about Observation"),
        Task(title: "Replace ObservableObject")
    ]

    var remaining: Int {
        tasks.filter { !$0.isDone }.count
    }

    func add(_ title: String) {
        tasks.append(Task(title: title))
    }
}

Computed properties like remaining are tracked too. Any view that reads it updates when a task's isDone changes.

Step 3: Bind to properties with @Bindable

@available(iOS 17, *)
struct TaskRow: View {
    @Bindable var task: Task

    var body: some View {
        HStack {
            Toggle("", isOn: $task.isDone)
                .labelsHidden()
            TextField("Title", text: $task.title)
                .strikethrough(task.isDone)
        }
    }
}

A plain let or var can read an @Observable object, but it can't give you $ bindings. @Bindable adds them, so $task.isDone and $task.title work with Toggle and TextField.

Step 4: Own the store with @State

@available(iOS 17, *)
struct TaskListView: View {
    @State private var store = TaskStore()
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            List {
                Section("\(store.remaining) remaining") {
                    ForEach(store.tasks) { task in
                        TaskRow(task: task)
                    }
                }
                Section {
                    TextField("New task", text: $newTitle)
                        .onSubmit {
                            guard !newTitle.isEmpty else { return }
                            store.add(newTitle)
                            newTitle = ""
                        }
                }
            }
            .navigationTitle("Tasks")
        }
    }
}

Use @State to create and own an @Observable object, in the place where you would have used @StateObject. The view keeps the same instance across re-renders.

Example usage

Because the project targets iOS 16.4, we wrap the view in an availability check with a fallback:

struct ContentView: View {
    var body: some View {
        if #available(iOS 17, *) {
            TaskListView()
        } else {
            Text("@Observable requires iOS 17 or later.")
                .padding()
        }
    }
}

If your app's minimum is iOS 17, you can drop the @available attributes and the fallback.

Key components explained

  1. @Observable
    • A macro that makes a class's stored properties observable.
    • Replaces ObservableObject plus @Published.
  2. @Bindable
    • Creates $ bindings to an @Observable object's properties.
    • Needed for controls like Toggle and TextField.
  3. @State
    • Owns an @Observable instance for the view's lifetime.
    • Replaces @StateObject.
  4. @available(iOS 17, *)
    • Observation needs iOS 17, so older deployment targets need a guard and a fallback.

Benefits

  • Less code. No @Published, no objectWillChange.
  • Fewer re-renders. Views update only for the properties they read.
  • Easier mental model. Fewer property wrappers to choose between.

Conclusion

@Observable is the modern way to share state in SwiftUI. Mark a class with the macro, own it with @State, and use @Bindable when you need bindings. If you are still using ObservableObject, it is worth migrating one model at a time.

If you found this article helpful, please consider supporting my work on Buy Me a Coffee. Don't forget to subscribe to my newsletter for more tutorials like this!

About the author

  • iOS developer from Toronto, ON, CAN

No comments yet. Be the first to comment.