Article by Pushkar Deshmukh
SwiftUI Observation in iOS 17+: Thread-Safe @Observable ViewModels for SwiftUI and UIKit
iOS 17 introduced Swift Observation, replacing ObservableObject and @Published. Learn how to build a thread-safe @Observable ViewModel, use it in SwiftUI without property wrappers, and integrate the same state layer with UIKit.
Pushkar Deshmukh
Senior iOS Engineer

Swift Observation: The Modern Way to Manage UI State
Starting in iOS 17, Apple introduced the Observation framework with the @Observable macro.
It replaces the old Combine-based pattern:
ObservableObject@Published@ObservedObject@StateObject
With Observation, Swift automatically tracks which properties your UI reads and updates the view when those values change.
But there is an important architectural rule:
@Observabletracks changes, but it does not guarantee thread safety.
For UI state, the safest pattern is to isolate your model using @MainActor.
Let's see a minimal counter example that works in both SwiftUI and UIKit.
Step 1 — Thread-Safe ViewModel
import Observation
@MainActor
@Observable
class CounterViewModel {
var count = 0
func increment() {
count += 1
}
}Why @MainActor?
UI state must be mutated on the main thread.@MainActor ensures:
Safe state mutation
No data races
Compile-time enforcement by Swift
SwiftUI Example (No Wrapper Needed)
With Observation, SwiftUI can track an observable model even without property wrappers, as long as the object is injected into the view.
import SwiftUI
struct CounterView: View {
let viewModel: CounterViewModel
var body: some View {
VStack(spacing: 20) {
Text("Count: \(viewModel.count)")
.font(.largeTitle)
Button("Increment") {
viewModel.increment()
}
}
.padding()
}
}SwiftUI automatically tracks:
viewModel.countWhenever count changes, the view refreshes.
No @ObservedObject.
No @StateObject.
No Combine.
Creating the ViewModel
The parent view owns the model and injects it:
struct RootView: View {
@State private var viewModel = CounterViewModel()
var body: some View {
CounterView(viewModel: viewModel)
}
}Use @State here because the view owns the instance and must preserve it across re-renders.
UIKit Example
UIKit does not automatically track dependencies, so we use withObservationTracking.
import UIKit
import Observation
class CounterViewController: UIViewController {
private let viewModel = CounterViewModel()
private let label = UILabel()
private let button = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
observe()
}
private func observe() {
withObservationTracking {
label.text = "Count: \(viewModel.count)"
} onChange: { [weak self] in
Task { @MainActor in
self?.observe()
}
}
}
@objc
private func buttonTapped() {
viewModel.increment()
}
}UIKit requires manual observation, but the same ViewModel works without modification.
SwiftUI vs UIKit with Observation
Feature | SwiftUI | UIKit |
|---|---|---|
Dependency tracking | Automatic | Manual |
State updates | Automatic | Manual |
Combine required | No | No |
Thread safety |
|
|
Key Takeaways
Modern iOS state management is much simpler:
Use
@Observableinstead ofObservableObjectUse
@MainActorfor thread-safe UI stateInject models directly into SwiftUI views
Use
@Stateonly when the view owns the instance
The result is cleaner architecture, less boilerplate, and safer UI state updates across SwiftUI and UIKit.




Comments
Loading comments…