Global AppState Architecture in SwiftUI

Published: (December 17, 2025 at 04:24 PM EST)
3 min read
Source: Dev.to

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.

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

  • AppState coordinates.
  • 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.

Back to Blog

Related posts

Read more »

Modular Feature Architecture in SwiftUI

🧩 1. What Is a Feature Module? A feature module is a self‑contained unit representing one functional chunk of your app: Home/ Profile/ Settings/ Feed/ Auth/ O...

Advanced Lists & Pagination in SwiftUI

Lists look simple — until you try to build a real feed. Then you hit problems like: - infinite scrolling glitches - duplicate rows - pagination triggering too o...

Teams management now moved to Settings

What’s new - “Teams” is no longer in the left-hand navigation menu. - All team management has moved to a new Settings → Teams page. - You’ll see a dismissible...