From ObservableObject to @observable
When I started BoxTime, I used ObservableObject with @Published properties everywhere. It worked, but the boilerplate was annoying and the performance was worse than it needed to be. Then iOS 17 shipped @Observable, and I migrated everything in an afternoon.
The Old Way
// Pre-iOS 17
class TimerManager: ObservableObject {
@Published var currentRound: Int = 1
@Published var remainingTime: TimeInterval = 0
@Published var phase: TimerPhase = .idle
@Published var isRunning: Bool = false
@Published var totalRounds: Int = 12
@Published var roundDuration: TimeInterval = 180
@Published var restDuration: TimeInterval = 60
}
struct TimerView: View {
@StateObject var timer = TimerManager()
// or @ObservedObject, @EnvironmentObject...
}
The problem: every time any @Published property changed, every view observing the object re-evaluated its body. If remainingTime updated 10 times per second, every view depending on TimerManager re-rendered 10 times per second -- even views that only read totalRounds, which almost never changes.
The New Way
@Observable
class TimerManager {
var currentRound: Int = 1
var remainingTime: TimeInterval = 0
var phase: TimerPhase = .idle
var isRunning: Bool = false
var totalRounds: Int = 12
var roundDuration: TimeInterval = 180
var restDuration: TimeInterval = 60
}
struct TimerView: View {
var timer: TimerManager
var body: some View {
// This view only re-renders when remainingTime or phase change
// because those are the only properties accessed in body
Text(formatTime(timer.remainingTime))
Text(timer.phase.label)
}
}
The @Observable macro automatically tracks which properties each view reads during its body evaluation. Only changes to accessed properties trigger re-renders. This is per-view granular observation, and it is a massive performance win.
Practical Architecture in BoxTime
BoxTime has a few observable objects:
@Observable
class TimerManager {
var currentRound = 1
var remainingTime: TimeInterval = 0
var phase: TimerPhase = .idle
// Configuration (rarely changes)
var config = WorkoutConfig()
// Computed properties work automatically
var displayTime: String {
let minutes = Int(remainingTime) / 60
let seconds = Int(remainingTime) % 60
return String(format: "%d:%02d", minutes, seconds)
}
func startWorkout() {
phase = .round
remainingTime = config.roundDuration
startTimer()
}
}
@Observable
class WorkoutConfig {
var rounds: Int = 12
var roundDuration: TimeInterval = 180
var restDuration: TimeInterval = 60
}
I separated WorkoutConfig from TimerManager because configuration is edited on one screen and consumed on another. Keeping them separate means the configuration view does not observe timer ticks, and the timer view does not care about config edits.
Environment Injection
Passing observable objects through the environment is cleaner with @Observable:
@main
struct BoxTimeApp: App {
@State private var timerManager = TimerManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(timerManager)
}
}
}
struct TimerView: View {
@Environment(TimerManager.self) var timer
var body: some View {
Text(timer.displayTime)
}
}
No more @EnvironmentObject -- just @Environment with the type. And you get compile-time type safety instead of runtime crashes when the object is missing.
One Gotcha: Bindings
Creating bindings to @Observable properties in SwiftUI requires @Bindable:
struct ConfigView: View {
@Environment(TimerManager.self) var timer
var body: some View {
@Bindable var timer = timer
Stepper("Rounds: \(timer.config.rounds)", value: $timer.config.rounds, in: 1...20)
}
}
This tripped me up during migration. The @Bindable wrapper must be declared inside body (or the property must be @Bindable at the struct level).
Migration Effort
Migrating BoxTime from ObservableObject to @Observable took about 3 hours. Most of it was removing @Published, @StateObject, and @ObservedObject annotations. The result: less code, better performance, and more intuitive data flow.
If you are building a new SwiftUI app today and targeting iOS 17+, there is no reason to use the old observation system. BoxTime runs noticeably smoother after the migration, especially on the timer screen where updates happen many times per second.
Top comments (0)