DEV Community

garry
garry

Posted on

Managing App State With SwiftUI @Observable

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...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)