Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 018 / 100

State management in SwiftUI in Swift, SwiftUI & iOS Apps

Where state lives and how it flows.

Expona Admin10 min read

State management in SwiftUI in Swift, SwiftUI & iOS Apps

Getting state management wrong in SwiftUI leads to views that don't update when they should, data that vanishes unexpectedly, or performance nightmares from excessive re-renders. State management in SwiftUI in Swift, SwiftUI & iOS Apps isn't magic—it's about understanding where each piece of data lives and ensuring changes flow through your view hierarchy predictably.

Property wrappers — Pick by ownership

SwiftUI gives us several property wrappers, each designed for a specific ownership pattern. Choosing the right one prevents bugs and keeps your code maintainable.

@State: Local view state

@State owns data directly in a view. When state changes, SwiftUI re-renders that view and its children. Use @State for data that belongs entirely to one view—toggles, text input, animation progress, or temporary UI state.

swift
struct LoginForm: View {
    @State private var email = ""
    @State private var password = ""
    @State private var isLoading = false
    
    var body: some View {
        VStack {
            TextField("Email", text: $email)
            SecureField("Password", text: $password)
            
            if isLoading {
                ProgressView()
            } else {
                Button("Sign In") {
                    isLoading = true
                    Task {
                        await authenticate(email: email, password: password)
                        isLoading = false
                    }
                }
            }
        }
    }
}

The private keyword matters—it signals that this state is internal to the view and shouldn't be accessed from outside.

@Binding: Two-way access to parent state

@Binding creates a two-way reference to state owned elsewhere, typically in a parent view. When a child modifies a binding, the parent updates. Use bindings when a child view needs to edit parent state without owning it.

swift
struct ParentView: View {
    @State private var selectedTab = 0
    
    var body: some View {
        VStack {
            TabSelector(selection: $selectedTab)
            ContentView(tabIndex: selectedTab)
        }
    }
}

struct TabSelector: View {
    @Binding var selection: Int
    
    var body: some View {
        Picker("Tab", selection: $selection) {
            Text("Home").tag(0)
            Text("Search").tag(1)
            Text("Profile").tag(2)
        }
        .pickerStyle(.segmented)
    }
}

Notice the $ prefix in the parent—that converts the state into a binding passed to the child. The child receives it as @Binding and can modify it directly.

@Observable model: Shared app or screen state

For data that multiple views need to observe—user session, cart contents, filter state—use @Observable (iOS 17+). Mark your model class with @Observable, and SwiftUI automatically tracks which properties each view accesses.

swift
import Observation

@Observable
class CartModel {
    var items: [CartItem] = []
    var total: Double = 0
    
    func addItem(_ item: CartItem) {
        items.append(item)
        updateTotal()
    }
    
    func removeItem(_ id: String) {
        items.removeAll { $0.id == id }
        updateTotal()
    }
    
    private func updateTotal() {
        total = items.reduce(0) { $0 + $1.price }
    }
}

struct ProductDetailView: View {
    @State private var cart = CartModel()
    
    var body: some View {
        VStack {
            Button("Add to cart") {
                cart.addItem(product)
            }
            
            NavigationLink("View cart") {
                CartView()
            }
        }
        .environment(cart)
    }
}

struct CartView: View {
    @Environment(CartModel.self) var cart
    
    var body: some View {
        List(cart.items) { item in
            HStack {
                Text(item.name)
                Spacer()
                Text("$\(item.price, format: .currency(code: "USD"))")
            }
        }
        .navigationTitle("Cart (\(cart.items.count))")
    }
}

The magic: only views that read cart.items re-render when items change. A view reading only cart.total stays stable. This fine-grained reactivity prevents unnecessary re-renders.

@Environment: Values passed down the tree

@Environment accesses values from the view hierarchy without explicitly passing them through every intermediate view. Use it for app-wide settings, theme, or accessibility preferences.

swift
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .preferredColorScheme(nil)
                .environment(\.locale, Locale(identifier: "de_DE"))
        }
    }
}

struct SettingsView: View {
    @Environment(\.locale) var locale
    @Environment(\.colorScheme) var colorScheme
    
    var body: some View {
        Text("Current locale: \(locale.identifier)")
        Text("Color scheme: \(colorScheme == .dark ? "Dark" : "Light")")
    }
}

SwiftUI's built-in environments (locale, colorScheme, dismiss) are always available. For custom data, create an environment key:

swift
private struct AnalyticsKey: EnvironmentKey {
    static let defaultValue = Analytics()
}

extension EnvironmentValues {
    var analytics: Analytics {
        get { self[AnalyticsKey.self] }
        set { self[AnalyticsKey.self] = newValue }
    }
}

// Usage
struct ContentView: View {
    @Environment(\.analytics) var analytics
    
    var body: some View {
        Button("Log event") {
            analytics.track("button_tapped")
        }
    }
}

Single source of truth

Each piece of data should be owned in exactly one place. If multiple views need the same data, they should read it from a single owner, not maintain separate copies.

This prevents conflicts and bugs. Imagine a user list where one view owns a copy of users and another view owns a different copy. One updates from the server; the other doesn't. You've created a sync nightmare.

Instead:

  • One model owns the data (@Observable or @State in a parent).
  • Child views receive a binding or read from environment.
  • Updates flow through one path.
swift
// ✅ Good: Single source of truth
@Observable
class UserListModel {
    var users: [User] = []
    var isLoading = false
    
    func loadUsers() async {
        isLoading = true
        users = await fetchUsers()
        isLoading = false
    }
}

struct UserListScreen: View {
    @State private var model = UserListModel()
    
    var body: some View {
        if model.isLoading {
            ProgressView()
        } else {
            List(model.users) { user in
                UserRow(user: user)
            }
        }
        .task {
            await model.loadUsers()
        }
    }
}

// ❌ Bad: Multiple owners
struct UserListScreenBad: View {
    @State private var users: [User] = []
    
    var body: some View {
        List(users) { user in
            UserRowBad(userID: user.id) // This might load its own copy
        }
    }
}

struct UserRowBad: View {
    let userID: String
    @State private var user: User? // Separate owner
}

Observation framework — @Observable replaces ObservableObject in iOS 17+

If you're supporting iOS 17 and later, migrate from ObservableObject and @Published to @Observable. The new framework is simpler, more efficient, and requires no Combine imports.

swift
// ❌ Old approach (still valid for iOS 16)
class OldUserModel: ObservableObject {
    @Published var name = ""
    @Published var email = ""
    @Published var isVerified = false
    
    func updateProfile(_ newName: String) {
        name = newName
    }
}

// ✅ New approach (iOS 17+)
@Observable
class UserModel {
    var name = ""
    var email = ""
    var isVerified = false
    
    func updateProfile(_ newName: String) {
        name = newName
    }
}

// Usage is nearly identical but simpler
struct ProfileView: View {
    @State private var userModel = UserModel()
    
    var body: some View {
        TextField("Name", text: $userModel.name)
    }
}

@Observable automatically tracks property access. Views only re-render if they access properties that changed, making performance tuning easier. No need to manually publish selected properties.

Keep views simple — Move logic into models

Views should describe UI, not business logic. Network calls, calculations, validation, and state mutations belong in model classes. This keeps views testable and reusable.

swift
// ❌ Logic in view
struct CheckoutViewBad: View {
    @State private var cart: [CartItem] = []
    @State private var isProcessing = false
    
    var body: some View {
        Button("Complete purchase") {
            isProcessing = true
            Task {
                do {
                    let total = cart.reduce(0) { $0 + $1.price }
                    let taxRate = 0.08
                    let tax = total * taxRate
                    let finalTotal = total + tax
                    
                    let response = try await URLSession.shared.data(
                        from: URL(string: "https://api.example.com/checkout")!
                    )
                    
                    isProcessing = false
                } catch {
                    isProcessing = false
                }
            }
        }
    }
}

// ✅ Logic in model
@Observable
class CheckoutModel {
    var cart: [CartItem] = []
    var isProcessing = false
    var total: Double {
        cart.reduce(0) { $0 + $1.price }
    }
    
    var tax: Double {
        total * 0.08
    }
    
    var finalTotal: Double {
        total + tax
    }
    
    func completeCheckout() async throws {
        isProcessing = true
        defer { isProcessing = false }
        
        let request = CheckoutRequest(total: finalTotal)
        _ = try await APIClient.checkout(request)
    }
}

struct CheckoutView: View {
    @State private var checkoutModel = CheckoutModel()
    
    var body: some View {
        Button("Complete purchase") {
            Task {
                try await checkoutModel.completeCheckout()
            }
        }
    }
}

This separation makes testing straightforward—test the model without rendering views—and makes the view's intent crystal clear at a glance.

Common mistakes

  • Using @State for shared data. If multiple views need the same value, don't give each one @State. Move it to a shared @Observable model or parent state.
  • Forgetting private on @State. External views shouldn't access a sibling's @State. Always mark it private.
  • Creating circular references. If Model A holds a reference to Model B, and Model B holds a reference to Model A (via environment), you can create retain cycles. Keep your dependency graph acyclic.
  • Storing view-only state in a persisted model. Don't bloat your @Observable models with UI ephemera like keyboard focus or animation state. Keep those in @State near the view that uses them.
  • Mixing bindings with environment. Pick one pattern per piece of state. Passing both a binding and environment reference to the same data confuses ownership.

Key takeaways

  • Use @State for view-local data, @Binding for parent state edits, @Observable for shared models, and @Environment for values passed down the tree.
  • Each value should have exactly one owner. Children read or bind to it; they don't duplicate it.
  • Migrate to @Observable on iOS 17+; it's simpler and more efficient than ObservableObject.
  • Keep logic out of views. Models handle calculations, validation, and side effects. Views describe UI.
  • Fine-grained reactivity in SwiftUI means views re-render only when the data they read changes. Structure state to leverage this.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles