State Management in SwiftUI with StateObject and EnviromentObject

Introduction

SwiftUI uses a declarative approach for building UIs. However, effectively managing the application's state is crucial for building maintainable and responsive apps. In this article, I will explain two key property wrappers, @StateObject and @EnvironmentObject, used for state management in SwiftUI, along with examples.

Understanding ObservableObject

In SwiftUI, the ObservableObject protocol is key for managing changes in your app's data. It allows objects to tell views they depend on (like buttons or text fields) whenever their properties update. This is done by marking important properties with @Published. When a @Published property is changed, SwiftUI automatically refreshes any views that are connected to that object, making sure they display the latest information.

StateObject. Managing State within a view

The @StateObject property wrapper is used to create and manage the lifecycle of an ObservableObject instance within a specific view. It essentially creates a new instance of the object whenever the view is created and ensures the object is deallocated when the view is destroyed.

Example. Shopping Cart with StateObject

import SwiftUI

struct Product: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
}

struct CartItem: Identifiable {
    let id = UUID()
    let product: Product
    var quantity: Int
}


class ShoppingCart: ObservableObject {
    @Published var items: [CartItem] = []
    
    // Add item to cart
    func addItem(_ product: Product) {
        if let index = items.firstIndex(where: { $0.product.id == product.id }) {
            items[index].quantity += 1
        } else {
            items.append(CartItem(product: product, quantity: 1))
        }
    }
    
    // Remove item from cart
    func removeItem(_ product: Product) {
        if let index = items.firstIndex(where: { $0.product.id == product.id }) {
            if items[index].quantity > 1 {
                items[index].quantity -= 1
            } else {
                items.remove(at: index)
            }
        }
    }
    
    // Get total price
    var totalPrice: Double {
        items.reduce(0) { $0 + ($1.product.price * Double($1.quantity)) }
    }
}

struct ContentView: View {
    @StateObject private var cart = ShoppingCart()
    
    let products = [
        Product(name: "Apple", price: 0.99),
        Product(name: "Banana", price: 0.59),
        Product(name: "Orange", price: 1.29)
    ]
    
    var body: some View {
        NavigationView {
            VStack {
                List(products) { product in
                    HStack {
                        Text(product.name)
                        Spacer()
                        Text("$\(product.price, specifier: "%.2f")")
                        Button(action: {
                            cart.addItem(product)
                        }) {
                            Image(systemName: "plus.circle")
                        }
                    }
                }
                
                NavigationLink(destination: CartView(cart: cart)) {
                    Text("View Cart (\(cart.items.count) items)")
                }
                .padding()
            }
            .navigationTitle("Products")
        }
    }
}

struct CartView: View {
    @ObservedObject var cart: ShoppingCart
    
    var body: some View {
        VStack {
            List {
                ForEach(cart.items) { item in
                    HStack {
                        Text(item.product.name)
                        Spacer()
                        Text("Qty: \(item.quantity)")
                        Text("$\(item.product.price * Double(item.quantity), specifier: "%.2f")")
                    }
                }
            }
            
            Text("Total: $\(cart.totalPrice, specifier: "%.2f")")
                .font(.largeTitle)
                .padding()
        }
        .navigationTitle("Shopping Cart")
    }
}

@main
struct ShoppingCartApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

EnvironmentObject. Sharing State Across Views

While @StateObject is ideal for managing state within a view, @EnvironmentObject comes into play when you need to share state across multiple views in the hierarchy. It allows a view to access and observe an ObservableObject instance that's created and provided by a parent view in the environment.

Example. User App theme with EnvironmentObject

First, define a Theme model and a ThemeManager class to handle the app's theme:

import SwiftUI

// Define a theme model
enum Theme: String, CaseIterable {
    case light
    case dark
    case blue

    var primaryColor: Color {
        switch self {
        case .light:
            return .white
        case .dark:
            return .black
        case .blue:
            return .blue
        }
    }

    var textColor: Color {
        switch self {
        case .light, .blue:
            return .black
        case .dark:
            return .white
        }
    }
}

The ThemeManager class manages the current theme and is marked with @ObservableObject so that views can react to changes.

class ThemeManager: ObservableObject {
    @Published var currentTheme: Theme = .light
}

Next, provide an instance of ThemeManager the SwiftUI environment in the main app entry point:

import SwiftUI

@main
struct MyApp: App {
    @StateObject private var themeManager = ThemeManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(themeManager)
        }
    }
}

Finally, use the ThemeManager environment object in your views. Here’s an example of a ContentView that toggles between light and dark mode:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var themeManager: ThemeManager

    var body: some View {
        VStack {
            Text("Current Mode: \(themeManager.isDarkMode ? "Dark" : "Light")")
                .padding()
            
            Button(action: {
                themeManager.isDarkMode.toggle()
            }) {
                Text("Toggle Theme")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
        .padding()
        .background(themeManager.isDarkMode ? Color.black : Color.white)
        .foregroundColor(themeManager.isDarkMode ? Color.white : Color.black)
        .animation(.easeInOut, value: themeManager.isDarkMode)
    }
}

Choosing Between StateObject and EnvironmentObject

The Choice between StateObject and EnviromentObject ultimately depends on the scope of your state management needs :

Use @StateObject When

  • The state data is specific to a single view and its subviews.
  • You want clear ownership and lifecycle management of the state object within the view.
  • Examples: Shopping cart contents for a cart view and form data for a specific form.

Use @EnvironmentObject When

  • The state data needs to be shared across multiple, potentially distant views in the hierarchy.
  • The state represents app-wide settings or global data.
  • Examples: User Theme settings, data fetched from a network call that multiple views need.

Here's a simple rule of thumb

  • Think local, use StateObject.
  • Think global or shared, use EnvironmentObject.


Similar Articles