SwiftUI's property wrappers make it easy to get started with state management, but as your app grows, @State and @Binding can become unwieldy. Here's how to structure state in production SwiftUI apps.

The Observable Pattern

With Swift 5.9 and iOS 17, the @Observable macro has become the preferred way to manage shared state. It's simpler than ObservableObject and works seamlessly with SwiftUI's view updates.

import SwiftUI
import Observation

@Observable
final class AppState {
    var user: User?
    var isLoading = false
    var errorMessage: String?
    
    private let authService: AuthService
    
    init(authService: AuthService = .shared) {
        self.authService = authService
    }
    
    func signIn(email: String, password: String) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await authService.signIn(email: email, password: password)
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func signOut() {
        user = nil
    }
}

Views automatically update when any observed property changes:

struct ProfileView: View {
    @Environment(AppState.self) private var appState
    
    var body: some View {
        if let user = appState.user {
            VStack {
                Text("Welcome, \(user.name)")
                Button("Sign Out") {
                    appState.signOut()
                }
            }
        } else {
            SignInView()
        }
    }
}

Separation of Concerns

Don't put all your logic in view models. Separate your concerns:

  • Views: Presentation only. Minimal logic.
  • View Models: View-specific state and user interactions.
  • Services: Business logic, networking, persistence.
  • Models: Data structures and domain logic.

This keeps each layer focused and testable.

Async/Await for Side Effects

Embrace Swift's concurrency model for any operation that involves waiting — network requests, database queries, file I/O:

@Observable
final class ArticleListViewModel {
    var articles: [Article] = []
    var isLoading = false
    
    private let articleService: ArticleService
    
    init(articleService: ArticleService) {
        self.articleService = articleService
    }
    
    func loadArticles() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            articles = try await articleService.fetchArticles()
        } catch {
            print("Failed to load articles: \(error)")
        }
    }
}

And in your view:

struct ArticleListView: View {
    @State private var viewModel: ArticleListViewModel
    
    var body: some View {
        List(viewModel.articles) { article in
            ArticleRow(article: article)
        }
        .task {
            await viewModel.loadArticles()
        }
    }
}

When to Use @State vs @Observable

Use @State for:

  • View-local, transient state (text field input, toggle states)
  • Simple values that don't need to be shared

Use @Observable for:

  • Shared state across multiple views
  • Complex state with business logic
  • State that needs to persist or sync

Testing State

Observable types are easy to test because they're just classes:

@Test
func testSignIn() async {
    let mockAuth = MockAuthService()
    let appState = AppState(authService: mockAuth)
    
    await appState.signIn(email: "test@example.com", password: "password")
    
    #expect(appState.user != nil)
    #expect(appState.errorMessage == nil)
}

No need for complex view testing when your state logic is isolated.

Conclusion

Modern SwiftUI state management is about choosing the right tool for the job. Use @Observable for shared state, embrace async/await for side effects, and keep your architecture clean with proper separation of concerns.

Your future self (and your team) will thank you.