Global AppState Architecture in SwiftUI
Source: Dev.to
Introduction
As SwiftUI apps grow, a common question arises: where does this state live?
Typical global concerns include:
- Authentication state
- Selected tab
- Deep links
- Onboarding flow
- Global errors
- App lifecycle events
- Feature coordination
Scattering this state across views and view models leads to unpredictable navigation, duplicated logic, hard‑to‑debug bugs, and state resetting at the wrong time.
The solution is a Global AppState – a clean, scalable architecture that avoids turning the app into a giant god object.
What belongs in AppState
AppState represents global, cross‑feature state that:
- Outlives individual screens
- Coordinates multiple features
- Reacts to lifecycle changes
- Drives navigation
- Survives view recreation
Typical examples:
- User session / auth status
- Selected tab
- Global navigation route
- Deep‑link handling
- App phase (foreground/background)
- Global loading / syncing flags
- Global error presentation
Rule of thumb
- If multiple features care about it → AppState
- If only one screen cares → ViewModel
What should NOT be in AppState
- Text field values
- Scroll positions
- Local animations
- Feature‑specific data lists
- Temporary UI toggles
Defining AppState with @Observable
@Observable
class AppState {
var session: UserSession?
var selectedTab: Tab = .home
var route: AppRoute?
var isSyncing = false
var pendingDeepLink: DeepLink?
}
This object lives for the lifetime of the app, is injected once, and drives high‑level behavior.
Injecting AppState
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
}
}
}
Accessing AppState in Views
@Environment(AppState.self) var appState
No singletons are required.
Navigation via AppState
enum AppRoute: Hashable {
case login
case home
case profile(id: String)
}
In the root view:
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .login:
LoginView()
case .home:
HomeView()
case .profile(let id):
ProfileView(userID: id)
}
}
Trigger navigation by updating state:
appState.route = .profile(id: user.id)
Benefits
- Predictable
- Testable
- Deep‑link friendly
Centralised Deep‑Link Handling
func handle(_ link: DeepLink) {
switch link {
case .profile(let id):
route = .profile(id: id)
case .home:
selectedTab = .home
}
}
Views no longer parse URLs; AppState translates external intent → internal state.
Reacting to Lifecycle Changes
@Environment(\.scenePhase) var phase
.onChange(of: phase) { newPhase in
switch newPhase {
case .background:
saveState()
case .active:
refreshIfNeeded()
default:
break
}
}
This keeps lifecycle logic out of views and view models.
Avoid Overloading AppState
Bad example
class AppState {
var feedPosts: [Post]
var profileViewModel: ProfileViewModel
var searchText: String
}
Good approach
AppStatecoordinates.- Feature‑specific view models own their data.
- Services handle side effects.
Scaling AppState with Sub‑states
@Observable
class AppState {
var sessionState = SessionState()
var navigationState = NavigationState()
var syncState = SyncState()
}
Each sub‑state has a clear responsibility and a limited surface area, scaling beautifully in large apps.
Testing AppState
func test_navigation_changes_route() {
let appState = AppState()
appState.route = .login
XCTAssertEqual(appState.route, .login)
}
func test_profile_deep_link() {
let appState = AppState()
appState.handle(.profile(id: "123"))
XCTAssertEqual(appState.route, .profile(id: "123"))
}
No UI is involved – tests focus on pure data.
State Flow
User Action
↓
ViewModel
↓
AppState (if global)
↓
View reacts
Benefits of a Well‑Designed Global AppState
- Simplifies navigation
- Stabilises lifecycle behavior
- Makes deep links trivial
- Removes global hacks
- Improves testability
- Scales across teams
When used with restraint, a Global AppState is one of the most powerful architectural tools in SwiftUI.