State in a Breathing App Is Deceptively Complex
On the surface, Lunair seems like it would have simple state: a breathing pattern is playing or it is not. In reality, the state graph is surprisingly deep once you account for interruptions, background transitions, accessibility events, and user preferences.
The State Machine Approach
Early on I used a collection of boolean flags — isPlaying, isPaused, isComplete. This quickly devolved into impossible states. Could something be both paused and complete? The flags said yes; reality said no.
I switched to an explicit state machine:
enum SessionState: Equatable {
case idle
case preparing(pattern: BreathPattern)
case active(phase: BreathPhase, cycleCount: Int)
case paused(resumePhase: BreathPhase, cycleCount: Int)
case completing(totalCycles: Int)
case completed(summary: SessionSummary)
}
Every possible state is explicitly modeled. The paused case carries the information needed to resume. The completing state handles the wind-down animation before showing results. No impossible combinations.
Managing Transitions
State transitions are handled through a single method that validates and applies changes:
class SessionManager: ObservableObject {
@Published private(set) var state: SessionState = .idle
func transition(to newState: SessionState) {
guard isValidTransition(from: state, to: newState) else {
assertionFailure("Invalid transition: \(state) -> \(newState)")
return
}
let oldState = state
state = newState
handleSideEffects(from: oldState, to: newState)
}
private func isValidTransition(
from: SessionState, to: SessionState
) -> Bool {
switch (from, to) {
case (.idle, .preparing): return true
case (.preparing, .active): return true
case (.active, .paused): return true
case (.active, .completing): return true
case (.paused, .active): return true
case (.paused, .idle): return true
case (.completing, .completed): return true
case (.completed, .idle): return true
default: return false
}
}
}
The assertionFailure catches invalid transitions during development without crashing in production. This has caught bugs multiple times — especially around background/foreground transitions triggering unexpected state changes.
Side Effects at the Boundary
All side effects — haptics, sound, analytics, timer management — happen in handleSideEffects, not scattered throughout the UI:
private func handleSideEffects(
from oldState: SessionState, to newState: SessionState
) {
switch (oldState, newState) {
case (_, .active(let phase, _)):
hapticEngine.signalPhase(phase)
timerManager.start(for: phase)
case (.active, .paused):
timerManager.pause()
hapticEngine.stop()
case (_, .completing(let cycles)):
hapticEngine.signalSessionComplete()
analytics.logSessionEnd(cycles: cycles)
default:
break
}
}
This centralization means the views stay purely declarative. They read state and render. That is it.
Persisting Across Interruptions
Phone calls, notifications, backgrounding — all of these can interrupt a breathing session. The state machine handles this cleanly:
func handleScenePhaseChange(_ phase: ScenePhase) {
switch phase {
case .background:
if case .active(let breathPhase, let count) = state {
transition(to: .paused(
resumePhase: breathPhase,
cycleCount: count
))
}
case .active:
// Don't auto-resume — let the user choose
break
default:
break
}
}
Auto-pausing on background is essential. Auto-resuming is not — returning to the app after a phone call should not immediately start a breathing cycle without the user being ready.
What I Would Do Differently
If I were starting Lunair today, I would reach for the Observation framework (@Observable) sooner. The ObservableObject + @Published pattern works, but the newer approach eliminates unnecessary view updates when unrelated published properties change.
The state machine pattern, though, I would not change. It has been the single most stabilizing architectural decision in the entire app.
Top comments (0)