Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

SwiftUI State Management Deep Dive

May 7, 2025 · 9 min read

iOS, SwiftUI, State Management, Swift, Interview Prep

Introduction

State management is the heart of SwiftUI applications. Understanding when to use each property wrapper - and how iOS 17's @Observable macro changes the game - is crucial for iOS interviews in 2025. This is consistently one of the most tested topics.

Building Glucoplate taught me that choosing the right state management approach early saves significant refactoring later. Let me share what actually matters.


@State: Local View State

@State is for simple, view-local state that the view owns:

struct CalorieCounter: View {
    @State private var currentCalories = 0
    @State private var showingDetails = false

    var body: some View {
        VStack {
            Text("\(currentCalories) calories")
                .font(.largeTitle)

            Button("Add 100") {
                currentCalories += 100
            }

            Button("Details") {
                showingDetails.toggle()
            }
        }
        .sheet(isPresented: $showingDetails) {
            CalorieDetailsView(calories: currentCalories)
        }
    }
}

Key Points About @State

  1. Always private - Only the view should modify its own @State
  2. Value types - Use for simple types (Int, String, Bool, structs)
  3. View-local - State lives and dies with the view
  4. Creates source of truth - SwiftUI manages the storage

Common Mistake

Don't use @State for reference types (classes). @State is designed for value types. For classes, use @StateObject or @Observable.


@Binding: Passing State Down

@Binding creates a two-way connection to state owned elsewhere:

struct NutritionEditor: View {
    @Binding var calories: Int
    @Binding var protein: Double

    var body: some View {
        Form {
            Stepper("Calories: \(calories)", value: $calories, in: 0...5000)

            HStack {
                Text("Protein:")
                TextField("g", value: $protein, format: .number)
                    .keyboardType(.decimalPad)
            }
        }
    }
}

// Parent creates the binding
struct MealLogView: View {
    @State private var mealCalories = 0
    @State private var mealProtein = 0.0

    var body: some View {
        NutritionEditor(
            calories: $mealCalories,
            protein: $mealProtein
        )
    }
}

Creating Bindings

// From @State
$stateVariable

// Constant binding (for previews)
.constant(100)

// Custom binding with getter/setter
Binding(
    get: { viewModel.selectedIndex },
    set: { viewModel.updateSelection($0) }
)

@StateObject vs @ObservedObject

This distinction causes confusion but is critical:

@StateObject: You Own It

class MealPlanViewModel: ObservableObject {
    @Published var meals: [Meal] = []
    @Published var isLoading = false

    func loadMeals() async {
        isLoading = true
        meals = await mealService.fetchMeals()
        isLoading = false
    }
}

struct MealPlanView: View {
    // View CREATES and OWNS this instance
    @StateObject private var viewModel = MealPlanViewModel()

    var body: some View {
        List(viewModel.meals) { meal in
            MealRow(meal: meal)
        }
        .task {
            await viewModel.loadMeals()
        }
    }
}

@ObservedObject: Someone Else Owns It

struct MealDetailView: View {
    // Passed from parent - don't create here
    @ObservedObject var viewModel: MealPlanViewModel

    var body: some View {
        Text("Meal count: \(viewModel.meals.count)")
    }
}

The Golden Rule

Use @StateObject when the view creates the object. Use @ObservedObject when the object is passed in. If you use @ObservedObject for an object you create inline, it will be recreated on every view update.

The Bug This Causes

// ❌ BUG: ViewModel recreated on every parent update
struct BrokenView: View {
    @ObservedObject var viewModel = SomeViewModel() // Wrong!
    // ...
}

// ✅ Correct: StateObject preserves across updates
struct CorrectView: View {
    @StateObject private var viewModel = SomeViewModel()
    // ...
}

@EnvironmentObject: Dependency Injection

@EnvironmentObject injects shared objects down the view hierarchy:

class UserSession: ObservableObject {
    @Published var user: User?
    @Published var isAuthenticated = false

    func signOut() {
        user = nil
        isAuthenticated = false
    }
}

// Inject at the top
@main
struct GlucoplateApp: App {
    @StateObject private var session = UserSession()

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

// Access anywhere in hierarchy
struct ProfileView: View {
    @EnvironmentObject var session: UserSession

    var body: some View {
        if let user = session.user {
            Text("Welcome, \(user.name)")
            Button("Sign Out") {
                session.signOut()
            }
        }
    }
}

Runtime Crash

If a view expects an @EnvironmentObject that wasn't provided, the app crashes at runtime. Always ensure the object is injected before the view hierarchy that needs it. Use optional environment objects or provide defaults in previews.


iOS 17's @Observable: The Game Changer

iOS 17 introduced the @Observable macro, which dramatically simplifies state management:

Before: ObservableObject Boilerplate

// Old way - lots of @Published
class NutritionViewModel: ObservableObject {
    @Published var foods: [Food] = []
    @Published var selectedFood: Food?
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var searchQuery = ""
}

After: @Observable Simplicity

// New way - just @Observable
@Observable
class NutritionViewModel {
    var foods: [Food] = []
    var selectedFood: Food?
    var isLoading = false
    var errorMessage: String?
    var searchQuery = ""

    // Computed properties automatically tracked
    var filteredFoods: [Food] {
        foods.filter { $0.name.contains(searchQuery) }
    }
}

Using @Observable in Views

struct FoodSearchView: View {
    // No property wrapper needed in many cases
    var viewModel: NutritionViewModel

    var body: some View {
        List(viewModel.filteredFoods) { food in
            FoodRow(food: food)
        }
        .searchable(text: $viewModel.searchQuery) // Still need $ for bindings
    }
}

@Bindable for Two-Way Binding

struct FoodEditorView: View {
    @Bindable var viewModel: NutritionViewModel

    var body: some View {
        TextField("Search", text: $viewModel.searchQuery)
    }
}

Fine-Grained Updates

The magic of @Observable: SwiftUI only updates views that access changed properties.

@Observable
class SettingsModel {
    var username = ""
    var notificationsEnabled = true
    var dailyGoal = 2000
}

struct UsernameView: View {
    var settings: SettingsModel

    var body: some View {
        // Only re-renders when username changes
        // Changes to notificationsEnabled don't trigger update
        Text(settings.username)
    }
}

Why @Observable Matters

With ObservableObject, any @Published property change re-renders all observing views. With @Observable, SwiftUI tracks which properties each view actually reads. This automatic fine-grained tracking eliminated many unnecessary re-renders in our app without manual optimization.


State Flow Patterns

Unidirectional Data Flow

@Observable
class FoodLogViewModel {
    private(set) var entries: [FoodEntry] = []
    private(set) var isLoading = false

    // Actions are the only way to mutate state
    func addEntry(_ food: Food, servings: Double) {
        let entry = FoodEntry(food: food, servings: servings)
        entries.append(entry)
        // Persist, sync, etc.
    }

    func removeEntry(at index: Int) {
        entries.remove(at: index)
    }
}

When to Use What

WrapperUse Case
@StateSimple, view-local values
@BindingPass state to child views
@StateObjectCreate & own ObservableObject
@ObservedObjectReceive ObservableObject from parent
@EnvironmentObjectInject shared object through hierarchy
@ObservableiOS 17+ class with automatic tracking
@EnvironmentSystem values (colorScheme, locale)

Interview Questions

Q1: What's the difference between @StateObject and @ObservedObject?

Answer: Both observe ObservableObject instances, but ownership differs:

  • @StateObject - View creates and owns the instance. Survives view recreation.
  • @ObservedObject - View receives instance from elsewhere. Doesn't control lifecycle.
// @StateObject: instance persists across view updates
@StateObject private var vm = ViewModel()

// @ObservedObject: if created inline, would be recreated
@ObservedObject var vm: ViewModel // Passed from parent

Using @ObservedObject for an inline-created object causes it to be recreated on every parent update, losing all state.

Q2: How does @Observable improve on ObservableObject?

Answer: Key improvements:

  1. Less boilerplate - No @Published on every property
  2. Fine-grained updates - Only views reading changed properties update
  3. Computed property tracking - Automatic dependency tracking
  4. Simpler in views - Often no property wrapper needed
// ObservableObject: ALL observers update when ANY @Published changes
// @Observable: Only observers of changed property update

Q3: When would you still use ObservableObject over @Observable?

Answer:

  1. iOS 16 support - @Observable requires iOS 17+
  2. Combine integration - ObservableObject works with objectWillChange publisher
  3. Custom change notification - Manual control over objectWillChange
  4. Third-party libraries - May expect ObservableObject

Q4: Explain @Environment vs @EnvironmentObject.

Answer:

  • @Environment - Reads system-provided values like colorScheme, locale, or custom EnvironmentKey values
  • @EnvironmentObject - Injects your own ObservableObject through the view hierarchy
@Environment(\.colorScheme) var colorScheme  // System value
@EnvironmentObject var session: UserSession   // Your object

Common Mistakes

1. Inline @ObservedObject Creation

// ❌ Recreated every render
struct BadView: View {
    @ObservedObject var vm = ViewModel()
}

// ✅ Persists correctly
struct GoodView: View {
    @StateObject private var vm = ViewModel()
}

2. Missing @Published

// ❌ Changes won't trigger updates
class ViewModel: ObservableObject {
    var count = 0  // Missing @Published!
}

// ✅ Proper publication
class ViewModel: ObservableObject {
    @Published var count = 0
}

3. Heavy Objects in @State

// ❌ Reference type in @State
@State private var viewModel = ViewModel()

// ✅ Use @StateObject for classes
@StateObject private var viewModel = ViewModel()

4. Not Providing EnvironmentObject

// ❌ Will crash if UserSession not injected
struct ProfileView: View {
    @EnvironmentObject var session: UserSession
}

// ✅ Always inject before view needs it
ContentView()
    .environmentObject(UserSession())

Migration Strategy: ObservableObject to @Observable

If targeting iOS 17+:

// Step 1: Add @Observable, keep ObservableObject temporarily
@Observable
class ViewModel: ObservableObject {
    @ObservationIgnored @Published var legacyProperty = ""
    var newProperty = ""
}

// Step 2: Gradually remove @Published
@Observable
class ViewModel {
    var allProperties = ""
}

// Step 3: Update views
// From: @StateObject/@ObservedObject
// To: just the type, or @Bindable for bindings

Summary

ConceptPurposeLifecycle
@StateView-local value stateView owns
@BindingTwo-way connectionDerived from owner
@StateObjectCreate/own ObservableObjectView owns
@ObservedObjectObserve passed objectExternal
@EnvironmentObjectShared object injectionApp lifetime
@ObservableModern automatic trackingExternal

The state management landscape in SwiftUI has evolved significantly. For new iOS 17+ projects, @Observable simplifies most patterns. Understanding the full picture - including why the older wrappers exist - demonstrates depth in interviews.


Part 2 of the iOS Interview Prep series.

← Previous

SwiftUI Navigation and Architecture Patterns

Next →

Mastering SwiftUI Foundations