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
- Always private - Only the view should modify its own @State
- Value types - Use for simple types (Int, String, Bool, structs)
- View-local - State lives and dies with the view
- 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
| Wrapper | Use Case |
|---|---|
@State | Simple, view-local values |
@Binding | Pass state to child views |
@StateObject | Create & own ObservableObject |
@ObservedObject | Receive ObservableObject from parent |
@EnvironmentObject | Inject shared object through hierarchy |
@Observable | iOS 17+ class with automatic tracking |
@Environment | System 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:
- Less boilerplate - No @Published on every property
- Fine-grained updates - Only views reading changed properties update
- Computed property tracking - Automatic dependency tracking
- 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:
- iOS 16 support - @Observable requires iOS 17+
- Combine integration - ObservableObject works with objectWillChange publisher
- Custom change notification - Manual control over objectWillChange
- 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
| Concept | Purpose | Lifecycle |
|---|---|---|
| @State | View-local value state | View owns |
| @Binding | Two-way connection | Derived from owner |
| @StateObject | Create/own ObservableObject | View owns |
| @ObservedObject | Observe passed object | External |
| @EnvironmentObject | Shared object injection | App lifetime |
| @Observable | Modern automatic tracking | External |
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.