As SwiftUI apps grow, one question always comes up:
“Where does this state live?”
- authentication state
- selected tab
- deep links
- onboarding flow
- global errors
- app lifecycle events
- feature coordination
If you scatter this state across views and ViewModels, you end up with:
- unpredictable navigation
- duplicated logic
- impossible-to-debug bugs
- state resetting at the wrong time
The solution is a Global AppState.
This post shows how to design a clean, scalable AppState architecture for SwiftUI — without turning your app into a giant god object.
🧠 What Is AppState?
AppState represents global, cross-feature state that:
- outlives individual screens
- coordinates multiple features
- reacts to lifecycle changes
- drives navigation
- survives view recreation
AppState is not for:
- local UI state
- per-screen form values
- temporary toggles
Think of it as the brain of the app, not the UI.
🧱 1. What Belongs in AppState (and What Doesn’t)
✅ Good AppState candidates
- user session / auth status
- selected tab
- global navigation route
- deep link handling
- app phase (foreground/background)
- global loading / syncing flags
- global error presentation
❌ What should NOT be in AppState
- text field values
- scroll positions
- local animations
- feature-specific data lists
- temporary UI toggles
Rule of thumb:
If multiple features care about it → AppState
If only one screen cares → ViewModel
🧩 2. Defining a Clean AppState Model
Use @Observable for modern SwiftUI:
@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
- drives high-level behavior
🌍 3. Injecting AppState at the App Root
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
}
}
}
Now every view can access it:
@Environment(AppState.self) var appState
No singletons.
No global variables.
Fully testable.
🧭 4. State-Driven Navigation
Navigation should respond to state, not side effects.
enum AppRoute: Hashable {
case login
case home
case profile(id: String)
}
In your 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)
Navigation becomes:
- predictable
- testable
- deep-link friendly
🔗 5. Deep Linking via AppState
Handle deep links centrally:
func handle(_ link: DeepLink) {
switch link {
case .profile(let id):
route = .profile(id: id)
case .home:
selectedTab = .home
}
}
Views do not parse URLs.
Features do not know about URLs.
AppState translates external intent → internal state.
🔄 6. App Lifecycle Integration
AppState is the perfect place to react 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 ViewModels.
⚠️ 7. Avoid the “God AppState” Anti-Pattern
Do not put everything in AppState.
Bad:
class AppState {
var feedPosts: [Post]
var profileViewModel: ProfileViewModel
var searchText: String
}
Good:
- AppState coordinates
- ViewModels own feature data
- Services handle side effects
If AppState grows too large:
👉 split into sub-states.
🧱 8. Composing AppState with Sub-States
@Observable
class AppState {
var sessionState = SessionState()
var navigationState = NavigationState()
var syncState = SyncState()
}
Each sub-state has:
- clear responsibility
- limited surface area
This scales beautifully in large apps.
🧪 9. Testing AppState
Because AppState is just data:
func test_navigation_changes_route() {
let appState = AppState()
appState.route = .login
XCTAssertEqual(appState.route, .login)
}
Test deep links:
func test_profile_deep_link() {
let appState = AppState()
appState.handle(.profile(id: "123"))
XCTAssertEqual(appState.route, .profile(id: "123"))
}
No UI.
No mocks.
No hacks.
🧠 10. Mental Model Cheat Sheet
- AppState = coordination
- ViewModels = feature logic
- Views = rendering + intent
- Services = side effects
State flows:
User Action
↓
ViewModel
↓
AppState (if global)
↓
View reacts
🚀 Final Thoughts
A well-designed Global AppState:
- simplifies navigation
- stabilizes lifecycle behavior
- makes deep links trivial
- removes global hacks
- improves testability
- scales across teams
It’s one of the most powerful architectural tools in SwiftUI — when used with restraint.
Top comments (0)