SwiftUI State Management Patterns for Real Apps
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.