DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Global AppState Architecture in SwiftUI

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

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

Now every view can access it:

@Environment(AppState.self) var appState
Enter fullscreen mode Exit fullscreen mode

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

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

Trigger navigation by updating state:

appState.route = .profile(id: user.id)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Test deep links:

func test_profile_deep_link() {
    let appState = AppState()
    appState.handle(.profile(id: "123"))
    XCTAssertEqual(appState.route, .profile(id: "123"))
}
Enter fullscreen mode Exit fullscreen mode

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

🚀 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)