DEV Community

garry
garry

Posted on

How I Handle State Management in a Health App

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

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

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

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

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)